Compare commits

...

69 Commits

Author SHA1 Message Date
Jon Staab cd70ca6654 Move renewed_at to tenant 2026-05-27 15:35:02 -07:00
Jon Staab f37bb55286 Significant refactor of activity reconciliation 2026-05-27 15:22:33 -07:00
Jon Staab 7a2baf6f82 Refactor billing to manage subscriptions/invoices internally 2026-05-27 10:27:13 -07:00
Jon Staab 28cd7b0a9a remove spec 2026-05-26 14:53:26 -07:00
Jon Staab 9d9192f681 Remove redundant relay.schema field 2026-05-25 16:15:00 -07:00
Jon Staab acf7ae8e0a Remove comments from env template 2026-05-25 14:47:52 -07:00
Jon Staab ebada70c0d Fix cors 2026-05-25 14:47:26 -07:00
Jon Staab 34d5e732f4 Add endpoint for paying an invoice so that users don't get expired qr codes 2026-05-25 11:09:24 -07:00
Jon Staab 384ddbd439 Sync frontend and backend 2026-05-22 11:03:55 -07:00
Jon Staab b4af2f3866 Update spec and readme 2026-05-22 10:15:52 -07:00
Jon Staab f8a0860045 Refactor billing/invoices a bit 2026-05-22 09:41:40 -07:00
Jon Staab 97b1bd9a02 Remove reconciliation step 2026-05-21 17:30:09 -07:00
Jon Staab e7c0e6fdbe Some lightning invoice refactoring 2026-05-21 17:19:35 -07:00
Jon Staab a998c9b833 Collapse multiple invoice tables into one 2026-05-21 16:23:20 -07:00
Jon Staab bf9a768b88 Inline some invoie route logic 2026-05-21 15:11:43 -07:00
Jon Staab f67ef5bca2 Move webhook handlers to stripe routes 2026-05-21 15:07:31 -07:00
Jon Staab c02d834fe0 Remove some stripe proxy methods 2026-05-21 14:29:38 -07:00
Jon Staab e6cbfb361e Simplify subscription syncing 2026-05-21 14:13:51 -07:00
Jon Staab 6d267ed339 Remove relay subscription item column 2026-05-20 11:16:02 -07:00
Jon Staab a654096f25 Refactor stripe module 2026-05-19 18:19:58 -07:00
Jon Staab b49d62f1dd Remove InvoiceLookupError 2026-05-19 17:29:47 -07:00
Jon Staab 2d5eb0ca84 Refactor commands 2026-05-19 17:20:00 -07:00
Jon Staab dde4b981b2 refactor query 2026-05-19 17:04:10 -07:00
Jon Staab 7134915665 Refactor infra 2026-05-15 14:30:26 -07:00
Jon Staab cfa52d739f Clean up relay validation 2026-05-15 13:15:57 -07:00
Jon Staab 6abe62b569 remove invoice auto collection on nwc_url update 2026-05-15 12:54:40 -07:00
Jon Staab cd7b84439e define defaults on the model, simplify create relay payload 2026-05-15 11:25:25 -07:00
Jon Staab 1c3e0d619a Refactor error handling 2026-05-15 11:07:27 -07:00
Jon Staab 5590b14074 Refactor api into different route files 2026-05-15 09:28:12 -07:00
Jon Staab 26f05e8b8f Add env struct 2026-05-14 15:33:28 -07:00
Jon Staab 066c91a4d1 Refactor bitcoin exchange rate fetching and wallet 2026-05-14 12:47:32 -07:00
userAdityaa 3ed021214a feat(infra): pass Blossom S3 config to Zooid with schema key prefix (#69)
Reviewed-on: coracle/caravel#69
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-13 15:47:08 +00:00
Jon Staab c0aff5f7cf Refactor billing module 2026-05-12 16:32:05 -07:00
Jon Staab c9c1dd2c4c Group subscription items by price 2026-05-12 15:53:17 -07:00
Jon Staab 679a56edc3 Add docker publish workflow 2026-05-12 14:48:50 -07:00
userAdityaa e7efd9d08b fix: stripe portal dead-end with callback return flow (#67)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-11 20:48:55 +00:00
userAdityaa 0151762362 chore: improve billing customer name using Nostr kind 0 with pubkey fallback (#66)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-08 22:52:13 +00:00
userAdityaa a79c43e17e feat: open payment modal immediately on relay plan upgrade (#64)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-07 18:35:24 +00:00
Jon Staab dbe25c372f Conflate id and schema 2026-05-05 17:47:13 -07:00
userAdityaa 80a86452d0 chore: encrypt tenant NWC URL at rest and stop secret exposure in tenant APIs (#58)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-05 20:42:12 +00:00
userAdityaa b1e3747ddb fix: manual Lightning payment reconciliation with Stripe invoice state (#54)
Reviewed-on: coracle/caravel#54
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 23:38:57 +00:00
userAdityaa 29f657635c fix: relay sync create/update classification to prevent false create mode on updates (#56)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 14:21:37 +00:00
userAdityaa 9556a34b19 fix: silent relay state drift when activity bus drops events (#53)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-29 18:36:36 +00:00
userAdityaa 3ecd285290 chore: prevent duplicate Lightning charges by adding durable invoice-level NWC payment guard (#51)
Reviewed-on: coracle/caravel#51
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 13:02:56 +00:00
userAdityaa 9f8fe7261f fix: add idempotency keys to all Stripe mutation calls (#49)
Reviewed-on: coracle/caravel#49
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 12:34:35 +00:00
userAdityaa 1aeb15971d fix: silent NWC auto-payment failure messaging in invoice.created fallback (#46)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 16:09:50 +00:00
userAdityaa 48f20dc1a5 fix: relay sync failures with delayed bounded retries (#45)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 15:32:09 +00:00
userAdityaa c261d8a146 fix: enforce relay member capacity limits from plan definitions (#43)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 20:56:03 +00:00
userAdityaa 21b36272b8 feat: add missing SQLite indexes for billing and API hot-path queries (#44)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 16:40:09 +00:00
userAdityaa a26bc1127d chore: strict Subdomain Validation with Detailed Error Messages (#42)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:09:37 +00:00
userAdityaa bc79da34cf feat: encourage payment setup for paid relays without making it required (#40)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:07:27 +00:00
userAdityaa 38e3a64312 feat: add confirmation dialog for relay deactivate/reactivate with explicit warnings (#41)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 21:25:23 +00:00
userAdityaa d209353abd docs: document delinquent relay status across spec (#35)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 18:14:14 +00:00
userAdityaa 08c9a2920b feat: display relay provisioning errors in UI (#39)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 18:08:47 +00:00
hodlbod c47727b909 Merge pull request 'Add tenant create endpoint' (#27) from create-tenant into master 2026-04-20 15:56:03 +00:00
Jon Staab 0705da8b09 Add tenant create endpoint 2026-04-20 15:55:56 +00:00
userAdityaa ca26d41eef fix: relay secret rotation on infra sync updates (#26)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-18 13:24:08 +00:00
userAdityaa 44f9928070 fix: make stripe webhooks explicitly toggleable with mandatory secret validation (#23)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 22:57:37 +00:00
Jon Staab 87dcf53d74 Change default backend port 2026-04-17 13:23:26 -07:00
userAdityaa bcbce5c058 chore: replace placeholder letter badges with actual SVG logos (#24)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 18:36:35 +00:00
userAdityaa 90e488d87e feat: add Nostrord to recommended apps (#22)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:23:20 +00:00
userAdityaa 334f05783f chore: harden relay plan validation to prevent billing bypass and plan-state drift (#20)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 21:35:43 +00:00
userAdityaa 145b511f9d docs(auth): document intentional session-style NIP-98 model (#16)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:40:50 +00:00
userAdityaa bac763c925 fix: invoice error mapping so Stripe 4xx responses are not returned as 500 (#17)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:39:35 +00:00
userAdityaa 85d37f53ce fix: respect activity_type in set_relay_status and include activate_relay (#14)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-15 20:39:06 +00:00
userAdityaa 072031d0c3 feat(frontend): handle bolt11 generation failures in payment dialog (#11)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 23:35:13 +00:00
userAdityaa ce595c8bc5 Ensure all tenants have valid Stripe customer IDs (#5)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 23:06:48 +00:00
userAdityaa 1d4034340b fix: invoice.paid reactivating manually deactivated relays (#10)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-14 22:10:40 +00:00
userAdityaa 9a8d02b286 fiat invoice to Lightning msat conversion by applying real-time BTC FX quotes (#7)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-13 21:05:21 +00:00
65 changed files with 3974 additions and 2890 deletions
+59
View File
@@ -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
View File
@@ -1,4 +1,5 @@
ref
todo.md
node_modules
target
data
+3 -3
View File
@@ -26,7 +26,7 @@ docker run -it \
-v ./config:/app/config \
-v ./media:/app/media \
-v ./data:/app/data \
ghcr.io/coracle-social/zooid
gitea.coracle.social/coracle/zooid
```
### 2. Configure the backend
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
cp frontend/.env.template frontend/.env
```
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box.
The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box.
### 4. Install dependencies and run
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
just dev
```
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`.
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`.
## Project docs
+19 -15
View File
@@ -1,32 +1,36 @@
# Server
HOST=127.0.0.1
PORT=3000
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth
ADMINS= # Comma-separated hex pubkeys with admin access
SERVER_HOST=127.0.0.1
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
SERVER_ADMIN_PUBKEYS=
APP_URL=http://localhost:5173
# Database
DATABASE_URL=sqlite://data/caravel.db
# Robot identity (published as kind 0)
ROBOT_SECRET= # Nostr private key (hex)
# Robot identity
ROBOT_SECRET=
ROBOT_NAME=
ROBOT_DESCRIPTION=
ROBOT_PICTURE=
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
ROBOT_WALLET=
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social
ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub
# Zooid
ZOOID_API_URL=http://127.0.0.1:3334
ZOOID_API_SECRET=
RELAY_DOMAIN=spaces.coracle.social
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# Blossom S3
BLOSSOM_S3_ENDPOINT=
BLOSSOM_S3_REGION=
BLOSSOM_S3_BUCKET=
BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
STRIPE_SECRET_KEY=
+13
View File
@@ -210,6 +210,7 @@ dependencies = [
"nostr-sdk",
"nwc",
"rand 0.8.5",
"regex",
"reqwest",
"serde",
"serde_json",
@@ -1894,6 +1895,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
+1
View File
@@ -24,6 +24,7 @@ hmac = "0.12"
sha2 = "0.10"
dotenvy = "0.15.7"
base64 = "0.22"
regex = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
+1 -1
View File
@@ -26,6 +26,6 @@ WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend
EXPOSE 3000
EXPOSE 2892
CMD ["/app/backend"]
+99 -37
View File
@@ -8,7 +8,7 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
- Axum (HTTP API)
- SQLx + SQLite
- Tokio (async runtime + workers)
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect)
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect, NIP-44 encryption at rest)
## Layout
@@ -16,43 +16,82 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
backend/
migrations/
0001_init.sql
spec/ # Module-by-module design notes
src/
api.rs # Axum routes + NIP-98 auth checks
billing.rs # Invoice generation + collection worker
infra.rs # Zooid sync worker
main.rs # App bootstrap
models.rs # DB models
repo.rs # Data access layer
robot.rs # Nostr robot identity + DM sending
main.rs # App bootstrap: load Env, build services, serve + spawn workers
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
web.rs # HTTP response envelope + helpers
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe)
models.rs # Domain models + sqlite rows
query.rs # Database reads
command.rs # Database writes + activity broadcast
pool.rs # SQLite pool + migrations
billing.rs # Stripe subscription reconciliation + Lightning collection worker
stripe.rs # Thin Stripe REST client
wallet.rs # NWC wallet handle (NIP-47)
bitcoin.rs # Fiat ↔ BTC/msats conversion
infra.rs # Zooid relay-sync worker
robot.rs # Nostr robot identity + DM sending
```
## 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 |
|---|---|---|
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `3000` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
**Server**
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 id) when a relay enables blossom.
| Variable | Description |
| ----------------------- | --------------------- |
| `BLOSSOM_S3_ENDPOINT` | S3 endpoint URL |
| `BLOSSOM_S3_REGION` | S3 region |
| `BLOSSOM_S3_BUCKET` | S3 bucket name |
| `BLOSSOM_S3_ACCESS_KEY` | S3 access key ID |
| `BLOSSOM_S3_SECRET_KEY` | S3 secret access key |
**Billing (Stripe)**
| Variable | Description |
| ----------------------- | ----------------------------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
## Schema and Architecture
@@ -60,16 +99,39 @@ See [spec](spec) for more details
## API Routes
All routes are NIP-98 protected.
Most API routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin)
- `POST /tenants`create current auth pubkey as tenant
- `POST /tenants`idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant)
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant)
- `GET /relays` — list relays (admin)
- `POST /relays` — create relay (admin or relay tenant)
- `GET /relays/:id` — get relay (admin or relay tenant)
- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant)
- `PUT /relays/:id` — update relay (admin or relay tenant)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
- `GET /invoices/:id` — get invoice (admin or same tenant)
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+68 -6
View File
@@ -4,7 +4,9 @@ CREATE TABLE IF NOT EXISTS activity (
created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL
resource_id TEXT NOT NULL,
billed_at INTEGER,
plan_id TEXT
);
CREATE TABLE IF NOT EXISTS tenant (
@@ -12,18 +14,16 @@ CREATE TABLE IF NOT EXISTS tenant (
nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT,
created_at INTEGER NOT NULL,
stripe_customer_id TEXT NOT NULL DEFAULT '',
stripe_subscription_id TEXT,
past_due_at INTEGER
billing_anchor INTEGER,
stripe_customer_id TEXT NOT NULL,
renewed_at INTEGER
);
CREATE TABLE IF NOT EXISTS relay (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
schema TEXT NOT NULL,
subdomain TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL,
stripe_subscription_item_id TEXT,
status TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
sync_error TEXT NOT NULL DEFAULT '',
@@ -39,3 +39,65 @@ CREATE TABLE IF NOT EXISTS relay (
push_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS invoice (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS invoice_item (
id TEXT PRIMARY KEY,
invoice_id TEXT,
activity_id TEXT,
tenant_pubkey TEXT NOT NULL,
relay_id TEXT NOT NULL,
plan TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS bolt11 (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
lnbc TEXT NOT NULL,
msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE TABLE IF NOT EXISTS intent (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_relay_tenant ON relay (tenant);
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant, created_at) WHERE billed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
-208
View File
@@ -1,208 +0,0 @@
# `pub struct Api`
Api manages the HTTP interface for the application
Members:
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
- `query: Query`
- `command: Command`
- `billing: Billing`
Notes:
- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
- Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message.
## `pub fn new() -> Self`
- Reads environment and populates members
## `pub fn router(&self) -> Result<()>`
- Returns an `axum::Router`
--- Plan routes
## `async fn list_plans(...) -> Response`
- Serves `GET /plans`
- No authentication required
- Return `data` is a list of plan structs from `Query::list_plans`
## `async fn get_plan(...) -> Response`
- Serves `GET /plans/:id`
- No authentication required
- Return `data` is a single plan struct matching `id`
- If plan does not exist, return `404` with `code=not-found`
--- Identity routes
## `async fn get_identity(...) -> Response`
- Serves `GET /identity`
- Authorizes anyone, but must be authorized
- If a tenant for the identity doesn't exist:
- Call the Stripe API to create a new customer
- Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- Return `data` is an `Identity` struct
--- Tenant routes
## `async fn list_tenants(...) -> Response`
- Serves `GET /tenants`
- Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants`
## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `query.get_tenant`
## `async fn update_tenant(...) -> Response`
- Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant
- Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct
## `async fn list_tenant_relays(...) -> Response`
- Serves `GET /tenants/:pubkey/relays`
- Authorizes admin or matching tenant
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
--- Relay routes
## `async fn list_relays(...) -> Response`
- Serves `GET /relays`
- Authorizes admin only
- Return `data` is a list of relay structs from `query.list_relays`
## `async fn get_relay(...) -> Response`
- Serves `GET /relays/:id`
- Authorizes admin or relay owner
- Return `data` is a single relay struct from `query.get_relay`
## `async fn create_relay(...) -> Response`
- Serves `POST /relays`
- Authorizes admin or matching tenant pubkey in request body
- Validates/prepares the relay data to be saved using `prepare_relay`
- Creates a new relay using `command.create_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct. Use HTTP `201`.
## `async fn update_relay(...) -> Response`
- Serves `PUT /relays/:id`
- Authorizes admin or relay owner
- Validates/prepares the relay data to be saved using `prepare_relay`
- Updates the given relay using `command.update_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct.
## `async fn list_relay_activity(...) -> Response`
- Serves `GET /relays/:id/activity`
- Authorizes admin or relay owner
- Get activity from `query.list_activity_for_relay`
- Return `data` is `{activity}`
## `async fn deactivate_relay(...) -> Response`
- Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner
- If relay is already inactive, 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
--- Utilities
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header
- Validates event kind and signature using `nostr_sdk`
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct)
- Does not validate `method` tag
- Returns pubkey if header all checks pass
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
## `require_admin(&self, authorized_pubkey: &str)`
- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain`
- If `plan` is free and `blossom` is enabled, return `premium-feature`
- If `plan` is free and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set
- Populate missing fields using reasonable defaults
-112
View File
@@ -1,112 +0,0 @@
# `pub struct Billing`
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query`
- `command: Command`
- `robot: Robot`
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members
## `pub fn start(&self)`
- Subscribes to `command.notify.subscribe()`
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`.
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
- Fetch the relay and tenant associated with the `activity`
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `inactive`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay 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`.
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
- Verify the webhook signature using `self.stripe_webhook_secret`
- Parse the event and dispatch by type:
- `invoice.created` -> `self.handle_invoice_created`
- `invoice.paid` -> `self.handle_invoice_paid`
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
- `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- Unknown event types are ignored (return Ok)
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
- Fetches invoices from Stripe API for the given customer
- Returns the `data` array from the Stripe response
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
- Fetches a single invoice from Stripe API by ID
- Returns the full Stripe invoice object
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
- Returns the bolt11 invoice string
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
- Creates a Stripe Customer Portal session for the given customer
- Returns the portal session URL
## `fn handle_invoice_created(&self, invoice: &Invoice)`
Attempts to pay a new subscription invoice. Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with `amount_due` of 0.
## `fn handle_invoice_paid(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `inactive` relays for the tenant that were deactivated due to non-payment (i.e. relays on paid plans that are inactive)
- Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- If tenant does not already have `past_due_at` set:
- Set `past_due_at` to now via `command.set_tenant_past_due`
- Send a DM via `robot.send_dm` notifying the tenant that their payment has failed and their relays may be deactivated if not resolved.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- Deactivate all active relays on paid plans via `command.deactivate_relay`
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Deactivate all active paid relays for the tenant via `command.deactivate_relay`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
-101
View File
@@ -1,101 +0,0 @@
# `pub struct Command`
Command writes to the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
- `pub notify: broadcast::Sender<Activity>` - callers can subscribe via `command.notify.subscribe()`
Notes:
- All public write methods should be atomic
- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
- `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`)
- After each successful commit, sends the `Activity` on the broadcast channel
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
- Creates the broadcast channel
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant, may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant_id)`
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates tenant
- Logs activity as `(update_tenant, tenant_id)`
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay, may throw sqlite uniqueness error on subdomain
- Sets relay status to `active`
- Logs activity as `(create_relay, relay_id)`
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay_id)`
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)`
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active`
- Logs activity as `(activate_relay, relay_id)`
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
- Sets `sync_error` on the relay
- Logs activity as `(fail_relay_sync, relay_id)`
## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, clears `sync_error`
- Logs activity as `(complete_relay_sync, relay_id)`
## `pub fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()>`
- Sets `stripe_subscription_item_id = null`
- Does not log activity
## `pub fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()>`
- Sets `stripe_subscription_item_id`
- Does not log activity
## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
- Sets `stripe_subscription_id` on the tenant
- Does not log activity
## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
- Sets `stripe_subscription_id = null` on the tenant
- Does not log activity
## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
- Sets `nwc_error` on the tenant
- Does not log activity
## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
- Sets `nwc_error = null` on the tenant
- Does not log activity
## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at` to the current timestamp
- Does not log activity
## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at = null` on the tenant
- Does not log activity
-34
View File
@@ -1,34 +0,0 @@
# `pub struct Infra`
Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`.
Members:
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
- `query: Query`
- `command: Command`
## `pub fn new(query: Query, command: Command) -> Self`
- Reads environment and populates members
## `pub async fn start(self)`
- Subscribes to `command.notify`
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
- Calls `sync_relay` and on success calls `command.complete_relay_sync`.
- On failure calls `command.fail_relay_sync`.
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
- Otherwise, sends `PUT /relay/:id` to update it.
- Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
-9
View File
@@ -1,9 +0,0 @@
# `async fn main() -> Result<()>`
- Configures logging
- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
- Get an axum router from `api.router`
- Adds CORS middleware based on `origins`
- Calls `axum::serve` with a listener
- Spawns `infra.start`
- Spawns `billing.start`
-93
View File
@@ -1,93 +0,0 @@
This file describes the domain model. This description should be translated into standard structs and sqlite schemas in a way that makes sense.
- Fields marked as private should use `#[serde(skip_serializing)]` in their definition.
- Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition.
# Identity
Identity is a description of a user.
- `pubkey` - the user's nostr pubkey
- `is_admin` - whether the user is an admin
# Activity
Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior.
- `id` - a random activity ID
- `tenant` - a tenant ID
- `created_at` - unix timestamp when the activity was created
- `activity_type` is one of:
- `create_tenant`
- `update_tenant`
- `create_relay`
- `update_relay`
- `activate_relay`
- `deactivate_relay`
- `fail_relay_sync`
- `complete_relay_sync`
- `resource_type` is a string identifying the resource type being modified.
- `resource_id` is a string identifying the resource id being modified.
# Plan
A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth.
- `id` - the plan slug
- `name` - the plan name
- `amount` - the plan monthly cost in USD
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
- `blossom` - whether blossom media hosting is available on this plan
- `livekit` - whether livekit audio/video calls are available on this plan
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
There are three plans available:
- `free` - $0/mo, up to 10 members, no blossom/livekit
- `basic` - $5/mo, up to 100 members, includes blossom/livekit
- `growth` - $25/mo, unlimited members, includes blossom/livekit
# Tenant
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
- `pubkey` is the nostr public key identifying the tenant
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
- `created_at` unix timestamp identifying tenant creation time
- `stripe_customer_id` a string identifying the associated stripe customer
- `stripe_subscription_id` (nullable) a string identifying the associated stripe subscription. Created when the first paid (non-free) relay is activated, deleted when the last paid relay is removed or deactivated. Free-only tenants have no subscription.
- `past_due_at` (nullable) unix timestamp when the tenant's subscription became past due. Set on payment failure, cleared on payment success.
# Relay
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - a random ID identifying the relay
- `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - `active|inactive`. Only `active` relays count toward billing.
- `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name
- `info_icon` - the relay's icon image URL
- `info_description` - the relay's description
- `policy_public_join` - whether to allow non-members to join the relay without an invite code
- `policy_strip_signatures` - whether to remove signatures when serving events to non-admins
- `groups_enabled` - whether NIP 29 groups are enabled
- `management_enabled` - whether NIP 86 management API is enabled
- `blossom_enabled` - whether blossom file storage is enabled
- `livekit_enabled` - whether livekit calls are enabled
- `push_enabled` - whether relay push is enabled
Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- The value of `inactive` is calculated based on `status`
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
- The relay's `roles` are hard-coded for now.
-14
View File
@@ -1,14 +0,0 @@
# `pub async fn create_pool() -> Result<SqlitePool>`
Creates and returns a sqlite connection pool.
Notes:
- Database table names are singular: `activity`, `tenant`, `relay`
Steps:
- Reads `DATABASE_URL` from environment
- Ensures that any directories referred to in `DATABASE_URL` exist
- Initializes the sqlx pool
- Runs migrations found in the `migrations` directory
-49
View File
@@ -1,49 +0,0 @@
# `pub struct Query`
Query reads from the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
- Returns matching tenant
## `pub fn list_plans() -> Vec<Plan>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- This is the source of truth for plan metadata exposed via API
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
- Returns matching relay
## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
- Returns the tenant matching the given `stripe_customer_id`
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
- Ordered newest-first
-25
View File
@@ -1,25 +0,0 @@
# `pub struct Robot`
Robot is a nostr identity which acts on behalf of the application.
Members:
- `secret: String` - a nostr secret key, from `ROBOT_SECRET`
- `name: String` - the name of the bot, from `ROBOT_NAME`
- `description: String` - the description of the bot, from `ROBOT_DESCRIPTION`
- `picture: String` - the picture URL for the bot, from `ROBOT_PICTURE`
- `outbox_client: nostr_sdk::Client` - used for publishing relay lists and metadata, connects to `ROBOT_OUTBOX_RELAYS`
- `indexer_client: nostr_sdk::Client` - used for publishing relay lists, connects to `ROBOT_INDEXER_RELAYS`
- `messagins_client: nostr_sdk::Client` - used for sending and receiving dms, connects to `ROBOT_MESSAGING_RELAYS`
## `pub fn new() -> Self`
- Reads environment and populates members. Relay urls should be split and normalized.
- Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
- Fetches recipient's outbox relays from `indexer_relays` (cached)
- Fetches recipient's messaging relays from their outbox relays (cached)
- Sends DM to recipient via their messaging relays
- If no outbox/messaging relays are found, return an error
+144 -790
View File
File diff suppressed because it is too large Load Diff
+549 -600
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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(&currency.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)
}
+386 -224
View File
@@ -1,71 +1,16 @@
use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast;
use sqlx::{Sqlite, Transaction};
use crate::models::{Activity, Relay, Tenant};
use crate::db::{pool, publish, with_tx};
use crate::models::{
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
RELAY_STATUS_INACTIVE, Relay, Tenant,
};
#[derive(Clone)]
pub struct Command {
pool: SqlitePool,
pub notify: broadcast::Sender<Activity>,
}
impl Command {
pub fn new(pool: SqlitePool) -> Self {
let (notify, _) = broadcast::channel(64);
Self { pool, notify }
}
async fn insert_activity(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
) -> Result<Activity> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
};
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&tenant)
.bind(created_at)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.execute(&mut **tx)
.await?;
Ok(Activity {
id,
tenant,
created_at,
activity_type: activity_type.to_string(),
resource_type: resource_type.to_string(),
resource_id: resource_id.to_string(),
})
}
fn emit(&self, activity: Activity) {
let _ = self.notify.send(activity);
}
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
// --- Tenants ---
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
@@ -74,47 +19,61 @@ impl Command {
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.bind(&tenant.stripe_customer_id)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey, None).await
})
.await?;
publish(activity);
Ok(())
}
let activity = Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey, None).await
})
.await?;
publish(activity);
Ok(())
}
let activity = Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
.bind(tenant.billing_anchor)
.bind(&tenant.pubkey)
.execute(pool())
.await?;
Ok(())
}
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
// --- Relays ---
pub async fn create_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"INSERT INTO relay (
id, tenant, schema, subdomain, plan, status, sync_error,
id, tenant, 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
@@ -128,22 +87,20 @@ impl Command {
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "create_relay", "relay", &relay.id, Some(&relay.plan)).await
})
.await?;
publish(activity);
Ok(())
}
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?;
pub async fn update_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
SET tenant = ?, 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 = ?,
@@ -151,7 +108,6 @@ impl Command {
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
@@ -167,143 +123,349 @@ impl Command {
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "update_relay", "relay", &relay.id, Some(&relay.plan)).await
})
.await?;
publish(activity);
Ok(())
}
let activity = Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay").await
}
tx.commit().await?;
self.emit(activity);
Ok(())
}
#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
pub async fn activate_relay(relay: &Relay) -> Result<()> {
set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
}
sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
async fn set_relay_status(relay_id: &str, status: &str, activity_type: &str) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status)
.bind(relay_id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, activity_type, "relay", relay_id, None).await
})
.await?;
publish(activity);
Ok(())
}
let activity = Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'active' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, "activate_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET sync_error = ? WHERE id = ?")
pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut *tx)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id, None).await
})
.await?;
publish(activity);
Ok(())
}
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?;
pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&mut *tx)
.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(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = ? WHERE pubkey = ?")
.bind(stripe_subscription_id)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_subscription_id = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
.bind(error)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
.bind(now)
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET past_due_at = NULL WHERE pubkey = ?")
.bind(pubkey)
.execute(&self.pool)
.await?;
Ok(())
}
insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id, None).await
})
.await?;
publish(activity);
Ok(())
}
// --- Invoice items (the outstanding-charge ledger) ---
pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activity_id: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
with_tx(async |tx| {
insert_invoice_item_tx(tx, invoice_item).await?;
mark_activity_billed_tx(tx, activity_id, now).await?;
Ok(())
})
.await
}
/// Mark an activity billed without a line item — for activities that produce no
/// charge (e.g. free-plan changes), so a recovery pass doesn't re-scan them.
pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
with_tx(async |tx| mark_activity_billed_tx(tx, activity_id, now).await).await
}
/// Insert renewal line items, skipping any relay already covered for the item's
/// `period_start`. The per-relay existence check and insert are a single
/// statement, so neither a re-tick nor a relay's own creation/activation charge
/// (which also stamps `period_start`) can bill the same relay-period twice.
pub async fn renew_tenant(
tenant_pubkey: &str,
period_start: i64,
items: &[InvoiceItem],
) -> Result<()> {
with_tx(async |tx| {
// In-tx guard: bail if this tenant has already been renewed for this
// period (or later). This is the correctness backstop — it keeps renewal
// idempotent under a crash mid-renewal or a poll racing the eager
// endpoint, since the item inserts and the `renewed_at` write commit
// together.
let renewed_at = sqlx::query_scalar::<_, Option<i64>>(
"SELECT renewed_at FROM tenant WHERE pubkey = ?",
)
.bind(tenant_pubkey)
.fetch_one(&mut **tx)
.await?;
if renewed_at.is_some_and(|at| at >= period_start) {
return Ok(());
}
for item in items {
insert_invoice_item_tx(tx, item).await?;
}
sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?")
.bind(period_start)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
// --- Invoices ---
/// Claim all of a tenant's outstanding items onto a new invoice — but only if
/// they sum to a positive amount. A non-positive balance (net credit or nothing
/// owed) leaves the items outstanding so the credit carries to the next positive
/// invoice. The sum, insert, and claim run in one transaction. Returns the
/// invoice, or `None` when there's nothing to bill.
pub async fn claim_outstanding_into_invoice(
invoice_id: &str,
tenant_pubkey: &str,
period_start: i64,
period_end: i64,
) -> Result<Option<Invoice>> {
with_tx(async |tx| {
let total = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
)
.bind(tenant_pubkey)
.fetch_one(&mut **tx)
.await?;
if total <= 0 {
return Ok(None);
}
let invoice =
insert_invoice_tx(tx, invoice_id, tenant_pubkey, period_start, period_end).await?;
sqlx::query(
"UPDATE invoice_item SET invoice_id = ?
WHERE tenant_pubkey = ? AND invoice_id IS NULL",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(Some(invoice))
})
.await
}
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
let updated_at = chrono::Utc::now().timestamp();
let activity = with_tx(async |tx| {
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
.bind(method)
.bind(updated_at)
.bind(invoice_id)
.execute(&mut **tx)
.await?;
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id, None).await
})
.await?;
publish(activity);
Ok(())
}
// --- Bolt11 records ---
pub async fn insert_bolt11(
invoice_id: &str,
lnbc: &str,
msats: i64,
expires_at: i64,
) -> Result<Option<Bolt11>> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Bolt11>(
"INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(lnbc)
.bind(msats)
.bind(created_at)
.bind(expires_at)
.fetch_optional(pool())
.await?)
}
pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
.bind(settled_at)
.bind(bolt11_id)
.execute(pool())
.await?;
Ok(())
}
// --- Intents ---
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
/// the same id and the re-insert is a no-op rather than a primary-key conflict.
pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO intent (id, invoice_id, created_at)
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
)
.bind(intent_id)
.bind(invoice_id)
.bind(created_at)
.execute(pool())
.await?;
Ok(())
}
// --- Internal utils that take an explicit transaction ---
async fn insert_activity_tx(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
plan_id: Option<&str>,
) -> Result<Activity> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
};
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id, plan_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&tenant)
.bind(created_at)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.bind(plan_id)
.execute(&mut **tx)
.await?;
Ok(Activity {
id,
tenant,
created_at,
activity_type: activity_type.to_string(),
resource_type: resource_type.to_string(),
resource_id: resource_id.to_string(),
billed_at: None,
plan_id: plan_id.map(str::to_string),
})
}
async fn insert_invoice_tx(
tx: &mut Transaction<'_, Sqlite>,
invoice_id: &str,
tenant_pubkey: &str,
period_start: i64,
period_end: i64,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
)
.bind(invoice_id)
.bind(tenant_pubkey)
.bind(period_start)
.bind(period_end)
.bind(now)
.bind(now)
.fetch_one(&mut **tx)
.await?)
}
async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &InvoiceItem) -> Result<()> {
sqlx::query(
"INSERT INTO invoice_item
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice_id)
.bind(&item.activity_id)
.bind(&item.tenant_pubkey)
.bind(&item.relay_id)
.bind(&item.plan)
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_activity_billed_tx(
tx: &mut Transaction<'_, Sqlite>,
activity_id: &str,
billed_at: i64,
) -> Result<()> {
sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ?")
.bind(billed_at)
.bind(activity_id)
.execute(&mut **tx)
.await?;
Ok(())
}
+108
View File
@@ -0,0 +1,108 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::OnceLock;
use anyhow::Result;
use sqlx::{
Sqlite, SqlitePool, Transaction,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::sync::broadcast;
use crate::env;
use crate::models::Activity;
/// Process-wide connection pool. Set once at startup via [`init`]; read
/// everywhere else via [`pool`], so command/query stay free functions instead of
/// threading a handle through every service.
static POOL: OnceLock<SqlitePool> = OnceLock::new();
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
/// [`publish`] after their transaction commits; reactors (billing, infra)
/// [`subscribe`] to react to durable changes.
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
/// Create the connection pool from `env`, run migrations, and store it as the
/// process-wide global. Panics if called more than once.
pub async fn init() -> Result<()> {
let pool = create_pool(&env::get().database_url).await?;
POOL.set(pool).expect("pool already initialized");
let (notify, _) = broadcast::channel(64);
NOTIFY.set(notify).expect("notify already initialized");
Ok(())
}
/// The global pool. Panics if [`init`] hasn't run yet.
pub fn pool() -> &'static SqlitePool {
POOL.get().expect("pool not initialized")
}
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
pub fn subscribe() -> broadcast::Receiver<Activity> {
NOTIFY.get().expect("notify not initialized").subscribe()
}
/// Broadcast an activity to subscribers. Called after the writing transaction
/// commits, so reactors only ever observe durable rows. A send with no current
/// subscribers is intentionally ignored.
pub fn publish(activity: Activity) {
if let Some(notify) = NOTIFY.get() {
let _ = notify.send(activity);
}
}
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
/// it returns an error. Returns whatever `f` produces. Callers compose the
/// transaction-scoped `command`/`query` functions inside `f` to make a
/// multi-step write atomic.
pub async fn with_tx<F, T>(f: F) -> Result<T>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
{
let mut tx = pool().begin().await?;
let value = f(&mut tx).await?;
tx.commit().await?;
Ok(value)
}
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+140
View File
@@ -0,0 +1,140 @@
use std::sync::OnceLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
/// Process-wide configuration, loaded once from the environment at startup via
/// [`init`] and read everywhere else via [`get`].
static ENV: OnceLock<Env> = OnceLock::new();
/// Load configuration from the environment and store it as the global. Panics
/// if a required variable is missing or if called more than once.
pub fn init() {
ENV.set(Env::load())
.unwrap_or_else(|_| panic!("env already initialized"));
}
/// The global configuration. Panics if [`init`] hasn't run yet.
pub fn get() -> &'static Env {
ENV.get().expect("env not initialized")
}
#[derive(Clone)]
pub struct Env {
pub server_host: String,
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
pub app_url: String,
pub database_url: String,
pub robot_name: String,
pub robot_wallet: String,
pub robot_picture: String,
pub robot_description: String,
pub robot_outbox_relays: Vec<String>,
pub robot_indexer_relays: Vec<String>,
pub robot_messaging_relays: Vec<String>,
pub blossom_s3_region: String,
pub blossom_s3_bucket: String,
pub blossom_s3_endpoint: String,
pub blossom_s3_access_key: String,
pub blossom_s3_secret_key: String,
pub zooid_api_url: String,
pub relay_domain: String,
pub livekit_url: String,
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_host: require_str("SERVER_HOST"),
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
app_url: require_str("APP_URL").trim_end_matches('/').to_string(),
database_url: require_str("DATABASE_URL"),
robot_name: require_str("ROBOT_NAME"),
robot_wallet: require_str("ROBOT_WALLET"),
robot_picture: require_str("ROBOT_PICTURE"),
robot_description: require_str("ROBOT_DESCRIPTION"),
robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"),
robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"),
robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"),
blossom_s3_region: require_str("BLOSSOM_S3_REGION"),
blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"),
blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"),
blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"),
blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"),
zooid_api_url: require_str("ZOOID_API_URL"),
relay_domain: require_str("RELAY_DOMAIN"),
livekit_url: require_str("LIVEKIT_URL"),
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
keys,
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
nip44::encrypt(
self.keys.secret_key(),
&self.keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(&self, ciphertext: &str) -> Result<String> {
nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&self.keys)
.await?;
Ok(auth)
}
}
fn require_str(key: &str) -> String {
let v = std::env::var(key)
.unwrap_or_else(|_| panic!("{key} is required"))
.trim()
.to_string();
if v.is_empty() {
panic!("{key} is required")
}
v
}
fn require_u16(key: &str) -> u16 {
require_str(key)
.parse()
.unwrap_or_else(|_| panic!("{key} is invalid"))
}
fn require_csv(key: &str) -> Vec<String> {
let v: Vec<String> = std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if v.is_empty() {
panic!("{key} is required");
}
v
}
+220 -89
View File
@@ -1,44 +1,31 @@
use anyhow::Result;
use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::models::Activity;
use crate::query::Query;
use crate::command;
use crate::db;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query;
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
#[derive(Clone)]
pub struct Infra {
api_url: String,
relay_domain: String,
livekit_url: String,
livekit_api_key: String,
livekit_api_secret: String,
api_secret: String,
query: Query,
command: Command,
}
pub struct Infra;
impl Infra {
pub fn new(query: Query, command: Command) -> Self {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
Self {
api_url,
relay_domain,
livekit_url,
livekit_api_key,
livekit_api_secret,
api_secret,
query,
command,
}
pub fn new() -> Self {
Self
}
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
let mut rx = db::subscribe();
if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop {
match rx.recv().await {
@@ -49,6 +36,10 @@ impl Infra {
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
if let Err(error) = self.reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
@@ -58,73 +49,136 @@ impl Infra {
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "deactivate_relay"
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
);
if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
if activity.resource_type != "relay" || !needs_sync {
return Ok(());
}
let is_new = relay.synced == 0;
self.sync_and_report(&relay, is_new).await;
if activity.activity_type == "fail_relay_sync" {
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(());
};
self.sync_relay(&relay).await;
Ok(())
}
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
let relays = query::list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
for relay in relays {
if relay.sync_error.trim().is_empty() {
self.sync_relay(&relay).await;
} else {
self.schedule_relay_sync_retry(&relay.id, source).await?;
}
}
Ok(())
}
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await {
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> {
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
let retry_attempt = consecutive_failures.max(1);
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
return None;
}
let exponent = (retry_attempt - 1).min(31);
let multiplier = 1u64 << exponent;
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
.saturating_mul(multiplier)
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
Some(Duration::from_secs(delay_secs))
}
let activities = query::list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count();
let Some(delay) = get_retry_delay(consecutive_failures) else {
tracing::warn!(
relay = relay_id,
consecutive_failures,
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
"relay sync retries exhausted; awaiting manual intervention"
);
return Ok(());
};
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let relay_id = relay_id.to_string();
let infra = self.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
match query::get_relay(&relay_id).await {
Ok(Some(relay)) => infra.sync_relay(&relay).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
}
});
Ok(())
}
async fn sync_relay(&self, relay: &Relay) {
match self.try_sync_relay(relay).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
if let Err(e) = command::complete_relay_sync(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
}
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await {
if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
}
}
}
}
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method).to_authorization(&keys).await?;
Ok(auth)
}
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> {
// A relay is "new" (POST with a freshly generated secret) only if it has
// never completed a sync. `synced == 1` short-circuits the activity lookup;
// otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
let is_new = relay.synced != 1
&& query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let host = if self.relay_domain.is_empty() {
relay.subdomain.clone()
} else {
format!("{}.{}", relay.subdomain, self.relay_domain)
};
let secret = Keys::generate().secret_key().to_secret_hex();
let livekit = if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"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 = serde_json::json!({
"host": host,
"schema": relay.schema,
"secret": secret,
"inactive": relay.status == "inactive",
let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
"schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
@@ -137,8 +191,32 @@ impl Infra {
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": { "enabled": relay.blossom_enabled == 1 },
"livekit": livekit,
"blossom": if relay.blossom_enabled == 1 {
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": env::get().blossom_s3_endpoint,
"region": env::get().blossom_s3_region,
"bucket": env::get().blossom_s3_bucket,
"access_key": env::get().blossom_s3_access_key,
"secret_key": env::get().blossom_s3_secret_key,
"key_prefix": relay.id,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": env::get().livekit_url,
"api_key": env::get().livekit_api_key,
"api_secret": env::get().livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
@@ -146,21 +224,74 @@ impl Infra {
},
});
let response = if is_new {
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
client.post(&url).header("Authorization", auth).json(&body).send().await?
} else {
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
client.put(&url).header("Authorization", auth).json(&body).send().await?
// 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?;
Ok(())
}
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
members: Vec<String>,
}
let response = self
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
.await?;
let parsed: MembersResponse = response.json().await?;
Ok(parsed.members)
}
// Internal utilities
/// Sends an authenticated request to the zooid API at `path` (relative to
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
/// text otherwise.
async fn request(
&self,
method: HttpMethod,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<reqwest::Response> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let base = env::get().zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = env::get().make_auth(&url, method).await?;
let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::PATCH => reqwest::Method::PATCH,
};
let 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() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid sync returned {}: {}", status, body)
let text = response.text().await.unwrap_or_default();
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
}
Ok(())
Ok(response)
}
}
+7 -1
View File
@@ -1,8 +1,14 @@
pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
pub mod db;
pub mod query;
pub mod robot;
pub mod routes;
pub mod stripe;
pub mod wallet;
pub mod web;
+33 -32
View File
@@ -1,23 +1,28 @@
mod api;
mod billing;
mod bitcoin;
mod command;
mod env;
mod infra;
mod models;
mod db;
mod query;
mod pool;
mod robot;
mod routes;
mod stripe;
mod wallet;
mod web;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
use crate::stripe::Stripe;
#[tokio::main]
async fn main() -> Result<()> {
@@ -28,35 +33,25 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let pool = pool::create_pool().await?;
env::init();
db::init().await?;
let robot = Robot::new().await?;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone());
let stripe = Stripe::new();
let infra = Infra::new();
let billing = Billing::new(robot.clone());
let api = Api::new(billing.clone(), stripe, robot, infra.clone());
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let cors = if origins.is_empty() {
CorsLayer::permissive()
} else {
let parsed = origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
CorsLayer::new().allow_origin(AllowOrigin::list(parsed))
};
let parsed = env::get()
.server_allow_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_methods(Any)
.allow_headers(Any);
let app = api.router().layer(cors);
@@ -68,7 +63,13 @@ async fn main() -> Result<()> {
billing.start().await;
});
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
let listener =
tokio::net::TcpListener::bind(format!(
"{}:{}",
env::get().server_host,
env::get().server_port
))
.await?;
axum::serve(listener, app).await?;
Ok(())
}
+82 -6
View File
@@ -1,5 +1,9 @@
use serde::{Deserialize, Serialize};
pub const RELAY_STATUS_ACTIVE: &str = "active";
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity {
pub id: String,
@@ -8,6 +12,10 @@ pub struct Activity {
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
pub billed_at: Option<i64>,
/// The relay's plan at the time of a `create_relay`/`update_relay` activity;
/// `None` for all other activity types.
pub plan_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -18,28 +26,27 @@ pub struct Plan {
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
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 pubkey: String,
pub nwc_url: String,
pub nwc_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
/// `period_start` of the most recent period this tenant was renewed for, or
/// `None` if never renewed. The per-period renewal idempotency marker.
pub renewed_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Relay {
pub id: String,
pub tenant: String,
pub schema: String,
pub subdomain: String,
pub plan: String,
pub stripe_subscription_item_id: Option<String>,
pub status: String,
pub sync_error: String,
pub info_name: String,
@@ -54,3 +61,72 @@ pub struct Relay {
pub push_enabled: i64,
pub synced: i64,
}
impl Default for Relay {
fn default() -> Self {
Self {
id: String::new(),
tenant: String::new(),
subdomain: String::new(),
plan: String::new(),
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: String::new(),
info_icon: String::new(),
info_description: String::new(),
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
synced: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice {
pub id: String,
pub tenant_pubkey: String,
pub status: String,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
/// `None` while outstanding; set once the item is claimed onto an invoice.
pub invoice_id: Option<String>,
/// `None` for renewal items, which have no source activity.
pub activity_id: Option<String>,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Bolt11 {
pub id: String,
pub invoice_id: String,
pub lnbc: String,
pub msats: i64,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
#[allow(dead_code)] // backs the `intent` table for the (not yet implemented) Stripe intent flow
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent {
pub id: String,
pub invoice_id: String,
pub created_at: i64,
}
-51
View File
@@ -1,51 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool() -> Result<SqlitePool> {
let raw_database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
let database_url = normalize_sqlite_url(&raw_database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options =
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+223 -147
View File
@@ -1,156 +1,232 @@
use anyhow::Result;
use sqlx::SqlitePool;
use crate::models::{Activity, Plan, Relay, Tenant};
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
use crate::db::pool;
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
}
impl Query {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn select_relay(tail: &str) -> String {
format!("SELECT * FROM relay {tail}")
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
ORDER BY pubkey",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
WHERE pubkey = ?",
)
// Plans
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
},
]
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
list_plans().into_iter().find(|p| p.id == plan_id)
}
// Tenants
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(pool())
.await?)
}
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
stripe_price_id: None,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
},
]
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE tenant = ?
ORDER BY id",
)
.bind(tenant_id)
.fetch_all(&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)
}
pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
WHERE stripe_customer_id = ?",
)
.bind(stripe_customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'",
)
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
.fetch_optional(pool())
.await?)
}
// Relays
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_for_tenant(tenant_id: &str) -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
.bind(tenant_id)
.fetch_all(pool())
.await?)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(pool())
.await?)
}
// Invoices
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result<Option<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(tenant_pubkey)
.fetch_optional(pool())
.await?)
}
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok(
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
.bind(invoice_id)
.fetch_all(pool())
.await?,
)
}
/// The relay's plan immediately before `before`, read from the activity log
/// (the most recent `create_relay`/`update_relay` with `created_at < before`).
/// Billing uses this as the `old` side of a plan-change delta.
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT plan_id FROM activity
WHERE resource_id = ?
AND created_at < ?
AND activity_type IN ('create_relay', 'update_relay')
AND plan_id IS NOT NULL
ORDER BY created_at DESC
LIMIT 1",
)
.bind(relay_id)
.bind(before)
.fetch_optional(pool())
.await?)
}
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
.bind(bolt11_id)
.fetch_optional(pool())
.await?)
}
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>(
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
// Activity
/// Billable activity for a tenant not yet folded into an invoice. The
/// activity-type filter and the `billed_at IS NULL` guard live here so the
/// caller reconciles off a precise marker rather than a timestamp watermark.
/// Ordered oldest-first so line items and proration apply in event order.
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant = ?
AND billed_at IS NULL
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
)
ORDER BY created_at ASC",
))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// A tenant's relay status/plan activity strictly before `before`, oldest-first
/// — folded by billing to reconstruct each relay's state as of a period boundary.
/// Strict `<` so a relay created exactly at the boundary isn't counted active
/// there (its own creation charge covers that period).
pub async fn list_relay_activity_before(
tenant_pubkey: &str,
before: i64,
) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant = ?
AND resource_type = 'relay'
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
)
AND created_at < ?
ORDER BY created_at ASC",
))
.bind(tenant_pubkey)
.bind(before)
.fetch_all(pool())
.await?)
}
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? ORDER BY created_at DESC",
))
.bind(resource_id)
.fetch_all(pool())
.await?)
}
pub async fn get_latest_activity_for_resource_and_type(
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(pool())
.await?)
}
+48 -106
View File
@@ -5,15 +5,10 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env;
#[derive(Clone)]
pub struct Robot {
secret: String,
name: String,
description: String,
picture: String,
outbox_client: Client,
indexer_client: Client,
messaging_client: Client,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
}
@@ -26,83 +21,59 @@ struct CacheEntry {
impl Robot {
pub async fn new() -> Result<Self> {
let secret = std::env::var("ROBOT_SECRET").unwrap_or_default();
if secret.trim().is_empty() {
return Err(anyhow!("ROBOT_SECRET is required"));
}
let name = std::env::var("ROBOT_NAME").unwrap_or_default();
let description = std::env::var("ROBOT_DESCRIPTION").unwrap_or_default();
let picture = std::env::var("ROBOT_PICTURE").unwrap_or_default();
let outbox_relays = split_relays("ROBOT_OUTBOX_RELAYS");
let indexer_relays = split_relays("ROBOT_INDEXER_RELAYS");
let messaging_relays = split_relays("ROBOT_MESSAGING_RELAYS");
if outbox_relays.is_empty() {
return Err(anyhow!("ROBOT_OUTBOX_RELAYS is required"));
}
if indexer_relays.is_empty() {
return Err(anyhow!("ROBOT_INDEXER_RELAYS is required"));
}
if messaging_relays.is_empty() {
return Err(anyhow!("ROBOT_MESSAGING_RELAYS is required"));
}
let outbox_client = client_with_relays(&secret, &outbox_relays).await?;
let indexer_client = client_with_relays(&secret, &indexer_relays).await?;
let messaging_client = client_with_relays(&secret, &messaging_relays).await?;
let robot = Self {
secret,
name,
description,
picture,
outbox_client,
indexer_client,
messaging_client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
};
robot
.publish_identity(&outbox_relays, &messaging_relays)
.await?;
robot.publish_identity().await?;
Ok(robot)
}
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(env::get().keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn publish_identity(
&self,
outbox_relays: &[String],
messaging_relays: &[String],
) -> Result<()> {
let mut metadata = Metadata::new();
if !self.name.is_empty() {
metadata = metadata.name(&self.name);
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
}
if !self.description.is_empty() {
metadata = metadata.about(&self.description);
if !env::get().robot_description.is_empty() {
metadata = metadata.about(&env::get().robot_description);
}
if !self.picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.picture)?);
if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
}
self.outbox_client
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = outbox_relays
let outbox_tags = env::get().robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.outbox_client
outbox_client
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = messaging_relays
let messaging_tags = env::get().robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.indexer_client
indexer_client
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
.await?;
@@ -123,14 +94,8 @@ impl Robot {
}
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.messaging_client.clone();
for relay in dm_relays {
let _ = client.add_relay(relay).await;
}
client.connect().await;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
let client = self.make_client(&dm_relays).await?;
client.send_private_msg(recipient_pubkey, message, []).await?;
Ok(())
}
@@ -141,10 +106,8 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let events = self
.indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await?;
let client = self.make_client(&env::get().robot_indexer_relays).await?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
let mut relays = Vec::new();
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
@@ -160,6 +123,22 @@ impl Robot {
Ok(relays)
}
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let client = self.make_client(&env::get().robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
Some(name)
}
async fn fetch_messaging_relays_from_outbox(
&self,
recipient: &str,
@@ -170,13 +149,7 @@ impl Robot {
}
let pubkey = PublicKey::parse(recipient)?;
let keys = Keys::parse(&self.secret)?;
let client = Client::new(keys);
for relay in outbox_relays {
client.add_relay(relay).await?;
}
client.connect().await;
let client = self.make_client(outbox_relays).await?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
@@ -195,37 +168,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(
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
key: &str,
+21
View File
@@ -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 })
}
+68
View File
@@ -0,0 +1,68 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey};
use crate::query;
use crate::web::{ApiResult, internal, not_found, ok};
/// The tenant's most recent invoice, after first materializing any outstanding
/// line items into a fresh one — so the frontend can collect payment right after
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller
/// drives it via the bolt11/Stripe endpoints. `null` when the tenant has no
/// invoices and nothing is outstanding.
pub async fn get_tenant_latest_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
// Roll any outstanding charges (and due renewals) into an invoice, then
// return the latest.
api.billing
.generate_invoice(&tenant)
.await
.map_err(internal)?;
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
ok(invoice)
}
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice)
}
pub async fn get_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let bolt11 = api
.billing
.ensure_and_reconcile_bolt11(&invoice_id)
.await
.map_err(internal)?;
ok(serde_json::json!(bolt11))
}
+5
View File
@@ -0,0 +1,5 @@
pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod tenants;
+18
View File
@@ -0,0 +1,18 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::Api;
use crate::query;
use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
ok(query::list_plans())
}
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match query::get_plan(&id) {
Some(plan) => ok(plan),
None => Err(not_found("plan not found")),
}
}
+311
View File
@@ -0,0 +1,311 @@
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
};
use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::{command, query};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::web::{
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
parse_bool_default, unprocessable,
};
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let relays = query::list_relays().await.map_err(internal)?;
ok(relays)
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
ok(relay)
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let activity = query::list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
ok(serde_json::json!({ "members": members }))
}
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
pub policy_public_join: i64,
pub policy_strip_signatures: i64,
pub groups_enabled: i64,
pub management_enabled: i64,
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
}
pub async fn create_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Json(payload): Json<CreateRelayRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let relay = Relay {
id: relay_id.clone(),
tenant: payload.tenant,
subdomain: payload.subdomain,
plan: payload.plan,
info_name: payload.info_name,
info_icon: payload.info_icon,
info_description: payload.info_description,
policy_public_join: payload.policy_public_join,
policy_strip_signatures: payload.policy_strip_signatures,
groups_enabled: payload.groups_enabled,
management_enabled: payload.management_enabled,
blossom_enabled: payload.blossom_enabled,
livekit_enabled: payload.livekit_enabled,
push_enabled: payload.push_enabled,
..Default::default()
};
let relay = prepare_relay(relay)?;
command::create_relay(&relay)
.await
.map_err(map_relay_write_error)?;
created(relay)
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
if let Some(v) = payload.subdomain {
relay.subdomain = v;
}
if let Some(v) = requested_plan.clone() {
relay.plan = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
}
if let Some(v) = payload.info_icon {
relay.info_icon = v;
}
if let Some(v) = payload.info_description {
relay.info_description = v;
}
if let Some(v) = payload.policy_public_join {
relay.policy_public_join = v;
}
if let Some(v) = payload.policy_strip_signatures {
relay.policy_strip_signatures = v;
}
if let Some(v) = payload.groups_enabled {
relay.groups_enabled = v;
}
if let Some(v) = payload.management_enabled {
relay.management_enabled = v;
}
if let Some(v) = payload.blossom_enabled {
relay.blossom_enabled = v;
}
if let Some(v) = payload.livekit_enabled {
relay.livekit_enabled = v;
}
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
let relay = prepare_relay(relay)?;
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan =
query::get_plan(&relay.plan).expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&api, &relay)
.await
.map_err(internal)?
.len() as i64;
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Err(unprocessable("member-limit-exceeded", &message));
}
}
}
command::update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
pub async fn deactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_INACTIVE {
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
}
command::deactivate_relay(&relay)
.await
.map_err(internal)?;
ok(())
}
pub async fn reactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_ACTIVE {
return Err(bad_request("relay-is-active", "relay is already active"));
}
command::activate_relay(&relay).await.map_err(internal)?;
ok(())
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
api.infra.list_relay_members(&relay.id).await
}
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = query::get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, 0);
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 0);
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay)
}
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
unprocessable("subdomain-exists", "subdomain already exists")
} else {
internal(e)
}
}
+178
View File
@@ -0,0 +1,178 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
#[derive(Serialize)]
pub struct TenantResponse {
pub pubkey: String,
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
}
impl From<Tenant> for TenantResponse {
fn from(t: Tenant) -> Self {
TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
created_at: t.created_at,
billing_anchor: t.billing_anchor,
stripe_customer_id: t.stripe_customer_id,
}
}
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let tenants = query::list_tenants().await.map_err(internal)?;
ok(tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>())
}
pub async fn create_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t));
}
let display_name = api
.robot
.fetch_nostr_name(&pubkey)
.await
.unwrap_or(pubkey.chars().take(8).collect());
let stripe_customer_id = api
.stripe
.create_customer(&pubkey, &display_name)
.await
.map_err(internal)?;
let tenant = Tenant {
pubkey: pubkey.clone(),
created_at: Utc::now().timestamp(),
stripe_customer_id,
..Default::default()
};
match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match query::get_tenant(&pubkey).await {
Ok(Some(t)) => ok(TenantResponse::from(t)),
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)),
}
}
Err(e) => Err(internal(e)),
}
}
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Json(payload): Json<UpdateTenantRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
// Encrypt tenant's nwc_url at rest
if let Some(nwc_url) = payload.nwc_url {
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
}
}
command::update_tenant(&tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant))
}
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = query::list_relays_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(relays)
}
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let invoices = query::list_invoices(&pubkey)
.await
.map_err(internal)?;
ok(invoices)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Query(params): Query<StripeSessionParams>,
) -> ApiResult {
api.require_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
+217
View File
@@ -0,0 +1,217 @@
//! A thin async wrapper around the subset of the Stripe REST API this service uses.
//!
//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP
//! to Stripe and hands back `serde_json::Value` (or small typed results). The
//! domain logic lives in [`crate::billing`].
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::env;
const STRIPE_API: &str = "https://api.stripe.com/v1";
// Stripe struct and impl
#[derive(Clone)]
pub struct Stripe {
http: reqwest::Client,
}
impl Stripe {
pub fn new() -> Self {
Self {
http: reqwest::Client::new(),
}
}
// --- Request helpers ---
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.get(format!("{STRIPE_API}{path}"))
.bearer_auth(&env::get().stripe_secret_key)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&env::get().stripe_secret_key)
}
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
mac.update(b":");
}
mac.update(part.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
// --- Customers ---
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
let body = self
.post("/customers")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_customer", tenant_pubkey]),
)
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
.send_json()
.await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
Ok(customer_id.to_string())
}
// --- Payment methods ---
/// Return the id of the customer's first saved payment method, or `None` if
/// they have none. The returned `pm_…` id can be charged off-session via
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
/// method, so the first one Stripe lists is the one we'll charge.
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
let body = self
.get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")])
.send_json()
.await?;
Ok(body["data"]
.as_array()
.and_then(|methods| methods.first())
.and_then(|method| method["id"].as_str())
.map(str::to_string))
}
// --- Intents ---
/// Create and immediately confirm an off-session PaymentIntent charging a
/// saved payment method. `amount` is in the currency's minor units (cents for
/// `usd`). Returns the PaymentIntent id on success.
///
/// A decline or an issuer authentication demand (`authentication_required`,
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
/// error, so the caller naturally falls through to another payment method.
/// The charge is made idempotent on `invoice_id`, so a retried collection
/// reuses the same charge instead of billing the payment method twice.
pub async fn create_payment_intent(
&self,
customer_id: &str,
payment_method_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
) -> Result<String> {
let amount = amount.to_string();
let body = self
.post("/payment_intents")
.header(
"Idempotency-Key",
self.idempotency_key(&["payment_intent", invoice_id]),
)
.form(&[
("amount", amount.as_str()),
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("off_session", "true"),
("confirm", "true"),
])
.send_json()
.await?;
// A successful off-session charge settles synchronously. Anything
// else (e.g. `requires_action`) can't be completed without the customer,
// so treat it as a failure and let the caller fall back.
let status = body["status"].as_str().unwrap_or_default();
if status != "succeeded" {
return Err(anyhow!("payment intent not succeeded (status: {status})"));
}
body["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing payment intent id"))
}
// --- Portal ---
pub async fn create_portal_session(
&self,
customer_id: &str,
return_url: Option<&str>,
) -> Result<String> {
let mut params = vec![("customer", customer_id.to_string())];
if let Some(url) = return_url {
params.push(("return_url", url.to_string()));
}
let body = self
.post("/billing_portal/sessions")
.form(&params)
.send_json()
.await?;
body["url"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
}
// Stripe request util
trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>;
}
impl StripeRequest for reqwest::RequestBuilder {
async fn send_ok(self) -> Result<reqwest::Response> {
error_for_status(self.send().await?).await
}
async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?)
}
}
/// Give callers an actionable message instead of a bare "400 Bad Request"
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if !status.is_client_error() && !status.is_server_error() {
return Ok(resp);
}
let url = resp.url().clone();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|json| {
let error = &json["error"];
let message = error["message"].as_str()?.to_string();
let mut detail = message;
if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) {
detail.push_str(&format!(" [{code}]"));
}
if let Some(param) = error["param"].as_str() {
detail.push_str(&format!(" (param: {param})"));
}
Some(detail)
})
.unwrap_or_else(|| {
if body.trim().is_empty() {
"<empty response body>".to_string()
} else {
body
}
});
Err(anyhow!(
"Stripe API request to {url} failed with status {status}: {detail}"
))
}
+57
View File
@@ -0,0 +1,57 @@
use anyhow::{Result, anyhow};
use nwc::prelude::{
LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest,
TransactionState,
};
#[derive(Clone)]
pub struct Wallet {
url: NostrWalletConnectURI,
}
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self { url: url.parse::<NostrWalletConnectURI>()? })
}
pub async fn make_invoice(
&self,
amount_msats: u64,
description: &str,
expiry_secs: u64,
) -> Result<String> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.make_invoice(MakeInvoiceRequest {
amount: amount_msats,
description: Some(description.to_string()),
description_hash: None,
expiry: Some(expiry_secs),
})
.await;
nwc.shutdown().await;
Ok(result
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
.invoice)
}
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
let nwc = NWC::new(self.url.clone());
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
nwc.shutdown().await;
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
}
pub async fn is_settled(&self, bolt11: &str) -> Result<bool> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.lookup_invoice(LookupInvoiceRequest {
payment_hash: None,
invoice: Some(bolt11.to_string()),
})
.await;
nwc.shutdown().await;
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some())
}
}
+125
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
# Backend API base URL
VITE_API_URL=http://127.0.0.1:3000
VITE_API_URL=http://127.0.0.1:2892
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
+6 -3
View File
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default |
|---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
## Running
@@ -51,8 +51,11 @@ npm run preview
## Authentication
- Tenant requests use NIP-98 tokens derived from the logged-in user
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend
- Tenant requests use an intentional session-style variant of NIP-98:
- The client signs one kind `27235` event with `u = VITE_API_URL`.
- The resulting `Authorization` header is cached for about 10 minutes to avoid repeated signer prompts.
- The backend validates signer identity + host affinity rather than exact URL/method binding per request.
- Admin routes require a pubkey listed in `ADMINS` on the backend.
## Routes
+1
View File
@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="220" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 32C0 14.3269 14.3269 0 32 0V0C49.6731 0 64 14.3269 64 32V32C64 49.6731 49.6731 64 32 64H8C3.58172 64 0 60.4183 0 56V32Z" fill="#6D29D9"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M21 22C19.3431 22 18 23.3431 18 25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25C14 21.134 17.134 18 21 18C24.866 18 28 21.134 28 25C28 26.1046 27.1046 27 26 27C24.8954 27 24 26.1046 24 25C24 23.3431 22.6569 22 21 22ZM43 22C41.3431 22 40 23.3431 40 25C40 26.1046 39.1046 27 38 27C36.8954 27 36 26.1046 36 25C36 21.134 39.134 18 43 18C46.866 18 50 21.134 50 25C50 26.1046 49.1046 27 48 27C46.8954 27 46 26.1046 46 25C46 23.3431 44.6569 22 43 22Z" fill="#FFFFFF"></path><path d="M32 47C38.6985 47 44.2982 42.2956 45.6755 36.0106C45.9829 34.608 44.5 33.5552 43.1016 33.8813C40.0379 34.5957 35.7213 35.1538 32 35.1538C28.2787 35.1538 23.9621 34.5957 20.8984 33.8813C19.5 33.5552 18.0171 34.608 18.3245 36.0106C19.7018 42.2956 25.3015 47 32 47Z" fill="#FFFFFF"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="66.145837" height="66.145837" viewBox="0 0 66.145837 66.145837" version="1.1" id="svg1" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" sodipodi:docname="nostrord.svg">
<sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" inkscape:zoom="2.1971895" inkscape:cx="172.94822" inkscape:cy="184.78151" inkscape:window-width="2048" inkscape:window-height="1083" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1"/>
<defs id="defs1"/>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-80.278336,-100.91589)">
<rect x="80.278336" y="100.91589" width="66.145836" height="66.145836" rx="15.434029" fill="#0a0a0a" id="rect1-6-9-7-7-9-8-6" style="stroke-width:1.10243"/>
<g id="g32" transform="matrix(0.56473291,0,0,0.56473291,-434.56644,229.88942)">
<path d="m 941.12408,-145.50974 c 0,7.28807 10.93211,10.93211 29.15233,10.93211 18.2201,0 29.1523,-3.64404 29.1523,-10.93211 v -32.79632 c 0,-14.57615 -10.9322,-25.50826 -29.1523,-25.50826 -18.22022,0 -29.15233,10.93211 -29.15233,25.50826 z" fill="#fafafa" id="path3-6-2-5-9-7-4-1" style="stroke-width:1.82202"/>
<path d="m 948.41215,-171.01799 c 0,-7.28807 10.93211,-10.93211 21.86426,-10.93211 10.9321,0 21.8642,3.64404 21.8642,10.93211 v 7.28807 c 0,7.28807 -10.9321,10.9321 -21.8642,10.9321 -10.93215,0 -21.86426,-3.64403 -21.86426,-10.9321 z" fill="#0a0a0a" id="path4-1-3-6-2-3-5-5" style="stroke-width:1.82202"/>
<ellipse cx="959.34424" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse4-5-7-2-0-6-0-5" style="stroke-width:1.82202"/>
<ellipse cx="981.2085" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse5-5-5-9-2-1-3-4" style="stroke-width:1.82202"/>
<g id="g12-2-3-2-6-7" transform="matrix(1.3286857,0,0,1.3286857,15.786725,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-7-9-1-6"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-5-3-0-5"/>
</g>
<g id="g12-2-2-9-1-6-6" transform="matrix(-1.3286857,0,0,1.3286857,1924.6621,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-6-2-9-3-9"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-1-2-4-2-3"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+151
View File
@@ -0,0 +1,151 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
+130 -46
View File
@@ -1,16 +1,16 @@
import { createEffect, createSignal, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
import { useTenantRelays } from "@/lib/hooks"
import { plans } from "@/lib/state"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = {
id: string
amount_due: number
}
type PaymentInvoice = Pick<Invoice, "id" | "amount_due"> &
Partial<Pick<Invoice, "period_start" | "period_end">>
type PaymentDialogProps = {
invoice: PaymentInvoice
@@ -21,20 +21,42 @@ type PaymentDialogProps = {
export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11, setBolt11] = createSignal("")
const [qrDataUrl, setQrDataUrl] = createSignal("")
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [relays] = useTenantRelays()
const billedRelays = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan) }))
.filter((entry) => entry.plan?.amount > 0)
})
async function loadBolt11() {
if (!props.invoice.id) return
setBolt11Status("loading")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
createEffect(async () => {
if (!props.open || !props.invoice.id) return
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
} catch {
// bolt11 generation may fail
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
setBolt11Error(e instanceof Error ? e.message : "Failed to generate Lightning invoice")
}
}
createEffect(() => {
if (!props.open || !props.invoice.id) return
void loadBolt11()
})
function copyBolt11() {
@@ -48,7 +70,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") {
setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else {
setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -62,14 +83,23 @@ export default function PaymentDialog(props: PaymentDialogProps) {
function handleClose() {
setPayStatus("idle")
setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
props.onClose()
}
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
const periodLabel = () => {
const { period_start, period_end } = props.invoice
if (!period_start || !period_end) return ""
const start = new Date(period_start * 1000).toLocaleDateString()
const end = new Date(period_end * 1000).toLocaleDateString()
return `${start} ${end}`
}
return (
<>
<Modal
@@ -84,6 +114,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<div>
<h2 class="text-lg font-semibold text-gray-900">Pay Invoice</h2>
<p class="text-2xl font-bold text-gray-900 mt-1">{amountLabel()}</p>
<Show when={periodLabel()}>
<p class="text-xs text-gray-500 mt-0.5">Billing period {periodLabel()}</p>
</Show>
</div>
<button
type="button"
@@ -103,34 +136,80 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<Show
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-3">
<Show
when={qrDataUrl()}
fallback={<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>}
>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<div class="w-full space-y-4">
{/* What's being paid for */}
<Show when={billedRelays().length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
<ul class="space-y-1.5">
<For each={billedRelays()}>
{({ relay, plan }) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
<span class="flex-shrink-0 text-xs text-gray-500">
{plan?.name ?? relay.plan}
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
</span>
</li>
)}
</For>
</ul>
</div>
</Show>
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide text-center">Pay with Lightning</p>
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Retry
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
{/* Card / automatic payment alternative */}
<div class="border-t border-gray-100 pt-3 text-center">
<p class="text-xs text-gray-500 mb-1">Prefer to pay with a card?</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
Pay with card or set up automatic payments
</button>
</div>
</div>
}
>
@@ -142,15 +221,13 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<Show when={showSetup()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</Show>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</div>
</Show>
</div>
@@ -188,7 +265,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading"}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
@@ -198,7 +275,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal>
<PaymentSetup
open={showPaymentSetup()}
onClose={() => setShowPaymentSetup(false)}
onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/>
</>
)
+5 -3
View File
@@ -9,6 +9,7 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
}
export default function PaymentSetup(props: PaymentSetupProps) {
@@ -27,6 +28,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
props.onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
@@ -38,7 +40,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey)
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
@@ -64,7 +66,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p>
</div>
<button
type="button"
@@ -144,7 +146,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p>
<button
type="button"
onClick={openPortal}
+69 -8
View File
@@ -2,6 +2,7 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
@@ -51,8 +52,8 @@ type RelayDetailCardProps = {
currentMembers?: number
showTenant?: boolean
editHref?: string
onDeactivate?: () => void
onReactivate?: () => void
onDeactivate?: () => void | Promise<void>
onReactivate?: () => void | Promise<void>
deactivating?: boolean
reactivating?: boolean
onTogglePublicJoin?: () => void
@@ -76,6 +77,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined
@@ -86,6 +88,24 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) {
setPlan(plan)
@@ -97,6 +117,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
}
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
createEffect(() => {
if (!menuOpen()) return
@@ -128,7 +171,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
@@ -148,7 +191,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative flex-shrink-0" ref={menuContainerRef}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
@@ -177,8 +220,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onDeactivate?.()
openActionDialog("deactivate")
}}
disabled={props.deactivating}
>
@@ -190,8 +232,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onReactivate?.()
openActionDialog("reactivate")
}}
disabled={props.reactivating}
>
@@ -203,6 +244,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<hr class="border-gray-200" />
<DetailSection title="Policy">
@@ -339,6 +387,19 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show>
</DetailSection>
</Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div>
)
}
+7
View File
@@ -1,6 +1,7 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state"
@@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) {
return
}
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("")
setSubmitting(true)
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api"
type RelayListItemProps = {
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)}
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
<Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
</span>
</Show>
</div>
</A>
</li>
+15 -7
View File
@@ -35,7 +35,6 @@ export type Plan = {
id: string
name: string
amount: number
stripe_price_id: string | null
members: number | null
blossom: boolean
livekit: boolean
@@ -46,12 +45,10 @@ export type PlanId = string
export type Relay = {
id: string
tenant: string
schema: string
subdomain: string
plan: PlanId
status: string
sync_error: string
stripe_subscription_item_id: string | null
synced: number
info_name: string
info_icon: string
@@ -98,7 +95,7 @@ export type UpdateRelayInput = {
export type Tenant = {
pubkey: string
nwc_url: string
nwc_is_set: boolean
created_at: number
stripe_customer_id: string
stripe_subscription_id: string | null
@@ -108,10 +105,10 @@ export type Tenant = {
export type Invoice = {
id: string
customer: string
status: string
amount_due: number
currency: string
hosted_invoice_url: string
period_start: number
period_end: number
}
@@ -145,6 +142,8 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235,
content: "",
created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]],
})
@@ -203,6 +202,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity")
}
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`)
}
@@ -235,6 +238,10 @@ export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
export function listRelayMembers(id: string) {
return callApi<undefined, { members: string[] }>("GET", `/relays/${id}/members`)
}
export function listRelayActivity(id: string) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
}
@@ -243,8 +250,9 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
}
export function createPortalSession(pubkey: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
export function createPortalSession(pubkey: string, returnUrl?: string) {
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
}
export function getInvoiceBolt11(invoiceId: string) {
+10 -11
View File
@@ -2,7 +2,6 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs"
import {
createRelay,
@@ -12,12 +11,14 @@ import {
getTenant,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
updateRelay,
updateTenant,
type Activity,
type CreateRelayInput,
type Invoice,
type Relay,
type Tenant,
type UpdateRelayInput,
@@ -134,17 +135,15 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_url && !tenant.stripe_subscription_id
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
}
export async function getRelayMembers(url: string) {
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
try {
return await management.listAllowedPubkeys()
} catch {
return []
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
export type { Activity, Relay, Tenant }
export type { Activity, Invoice, Relay, Tenant }
+22
View File
@@ -0,0 +1,22 @@
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
export function validateSubdomainLabel(subdomain: string): string | null {
if (subdomain.length === 0) {
return "subdomain is required"
}
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
return "subdomain must be 63 characters or fewer"
}
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
return "subdomain cannot start or end with a hyphen"
}
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
return "subdomain is reserved"
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return "subdomain may only contain lowercase letters, numbers, and hyphens"
}
return null
}
+11 -6
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import type { PlanId } from "@/lib/api"
import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
@@ -30,7 +30,8 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions,
) {
const [busy, setBusy] = createSignal(false)
const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) {
mutate(next)
@@ -101,8 +102,12 @@ export default function useRelayToggles(
}
if (plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) setNeedsPaymentSetup(true)
const needsSetup = await tenantNeedsPaymentSetup()
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"),
}
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles }
}
+24 -10
View File
@@ -1,10 +1,11 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, listTenantInvoices, type Invoice } from "@/lib/api"
import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
export default function Account() {
@@ -17,15 +18,22 @@ export default function Account() {
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
const hasBillingChanges = createMemo(() => {
const current = tenant()?.nwc_url?.trim() ?? ""
const next = nwcUrl().trim()
return current !== next
})
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
// invoice and opens the payment dialog. The fetched invoice takes precedence
// over a row the user clicked in the list.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinkedInvoice] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
createEffect(() => {
setNwcUrl(tenant()?.nwc_url ?? "")
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
})
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
// The backend never returns the stored nwc_url (it's private), so the input is
// write-only: we can only act on a newly entered URL, not prefill the saved one.
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
async function saveBilling() {
setError("")
@@ -33,6 +41,7 @@ export default function Account() {
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
setNwcUrl("")
await refetchTenant()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
@@ -43,13 +52,15 @@ export default function Account() {
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
// Clearing the query param drops the deep-linked invoice and closes the dialog.
if (searchParams.invoice) setSearchParams({ invoice: undefined })
void refetchInvoices()
}
async function openPortal() {
setPortalLoading(true)
try {
const { url } = await createPortalSession(account()!.pubkey)
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
@@ -111,6 +122,9 @@ export default function Account() {
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
</p>
<Show when={tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
<input
type="text"
@@ -188,7 +202,7 @@ export default function Account() {
</section>
</div>
<Show when={selectedInvoice()}>
<Show when={activeInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
+47 -8
View File
@@ -8,6 +8,9 @@ import Modal from "@/components/Modal"
import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
export default function Home() {
const navigate = useNavigate()
@@ -89,7 +92,7 @@ export default function Home() {
<h1 class="text-5xl sm:text-6xl font-extrabold tracking-tight text-gray-900 mb-6 leading-tight">
Your community,<br />
<span class="text-blue-600">your relay.</span>
<span class="text-blue-600">your server.</span>
</h1>
<p class="text-xl text-gray-500 mb-10 max-w-2xl mx-auto leading-relaxed">
@@ -213,7 +216,7 @@ export default function Home() {
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Flotilla */}
<a
href="https://flotilla.social"
@@ -223,9 +226,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
F
</div>
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3>
<p class="text-xs text-gray-400">flotilla.social</p>
@@ -263,9 +264,7 @@ export default function Home() {
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
C
</div>
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p>
@@ -293,6 +292,45 @@ export default function Home() {
</span>
</div>
</a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
<p class="text-xs text-gray-400">nostrord.com</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A NIP-29 client built for decentralized group chat on Nostr. Create
censorship-resistant communities with admin roles, moderation, and access
controlall powered by your relay.
</p>
<div class="space-y-2">
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
Visit nostrord.com <ExternalLinkIcon />
</span>
</div>
</a>
</div>
</section>
@@ -338,6 +376,7 @@ export default function Home() {
<div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
</div>
</div>
</footer>
+12 -1
View File
@@ -1,11 +1,12 @@
import { useParams } from "@solidjs/router"
import { Show } from "solid-js"
import { createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
@@ -13,6 +14,15 @@ export default function AdminRelayDetail() {
const params = useParams()
const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId)
const [members] = createResource(relayId, async (id) => {
if (!id) return []
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
})
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
@@ -26,6 +36,7 @@ export default function AdminRelayDetail() {
<div class="space-y-6 mb-6">
<RelayDetailCard
relay={r()}
currentMembers={members()?.length}
showTenant
editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
+115 -10
View File
@@ -1,27 +1,66 @@
import { useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId)
const relayUrl = createMemo(() => {
const subdomain = relay()?.subdomain
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
const [members] = createResource(relayId, async (id) => {
if (!id) return []
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
})
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
setPaymentSetupOpen(true)
clearPendingPaymentSetup()
}
})
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_is_set
})
return (
<PageContainer>
@@ -30,9 +69,47 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members.length}
currentMembers={members()?.length}
editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
@@ -45,9 +122,37 @@ export default function RelayDetail() {
</div>
)}
</Show>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
}}
/>
)}
</Show>
<PaymentSetup
open={needsPaymentSetup()}
onClose={clearNeedsPaymentSetup}
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
}}
/>
</PageContainer>
)
+1 -2
View File
@@ -1,7 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
@@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), {
subdomain: slugify(values.subdomain),
subdomain: values.subdomain,
info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(),
+32 -10
View File
@@ -1,14 +1,17 @@
import { createSignal } from "solid-js"
import { createSignal, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() {
const navigate = useNavigate()
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) {
@@ -16,9 +19,14 @@ export default function RelayNew() {
createdRelayId = relay.id
if (values.plan !== "free") {
const needs = await tenantNeedsPaymentSetup()
if (needs) {
setShowPaymentSetup(true)
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return
}
}
@@ -26,8 +34,13 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`)
}
function handleDialogClose() {
setShowPaymentSetup(false)
function handleInvoiceClose() {
setPendingInvoice(undefined)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`)
}
@@ -41,9 +54,18 @@ export default function RelayNew() {
submitLabel="Create Relay"
submittingLabel="Creating..."
/>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup
open={showPaymentSetup()}
onClose={handleDialogClose}
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</PageContainer>
)
+7
View File
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner"
import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
@@ -69,6 +70,12 @@ export default function Login(props: LoginPageProps = {}) {
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account)
accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.()
}
+14 -7
View File
@@ -1,13 +1,20 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
},
server: {
port: Number(env.VITE_PORT) || 5173,
},
}
})
+4 -1
View File
@@ -5,6 +5,9 @@ dev:
cd frontend && bun dev &
wait
dev-backend:
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
dev-frontend:
cd frontend && bun run dev
@@ -27,7 +30,7 @@ build-backend:
cd backend && cargo build
build-frontend:
cd frontend && bun run build
cd frontend && bun i && bun run build
fmt: fmt-backend
-2
View File
@@ -1,2 +0,0 @@
- [ ] Fix billing by using stripe as a backend to do proration, then mark invoices paid manually when using bitcoin.
- [ ] Send a payment link instead of an invoice so we can generate/pay on the fly