Compare commits

..

1 Commits

71 changed files with 2938 additions and 4274 deletions
-59
View File
@@ -1,59 +0,0 @@
name: Docker
on:
push:
branches: [master]
env:
REGISTRY: gitea.coracle.social
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- component: frontend
image: coracle/caravel-frontend
context: frontend
- component: backend
image: coracle/caravel-backend
context: backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.image }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-1
View File
@@ -1,5 +1,4 @@
ref ref
todo.md
node_modules node_modules
target target
data data
+3 -3
View File
@@ -26,7 +26,7 @@ docker run -it \
-v ./config:/app/config \ -v ./config:/app/config \
-v ./media:/app/media \ -v ./media:/app/media \
-v ./data:/app/data \ -v ./data:/app/data \
gitea.coracle.social/coracle/zooid ghcr.io/coracle-social/zooid
``` ```
### 2. Configure the backend ### 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 cp frontend/.env.template frontend/.env
``` ```
The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box. The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box.
### 4. Install dependencies and run ### 4. Install dependencies and run
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
just dev just dev
``` ```
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`. This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`.
## Project docs ## Project docs
+13 -19
View File
@@ -1,8 +1,10 @@
# Server # Server
SERVER_HOST=127.0.0.1 HOST=127.0.0.1
SERVER_PORT=2892 PORT=3000
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
# Auth
ADMINS= # Comma-separated hex pubkeys with admin access
# Database # Database
DATABASE_URL=sqlite://data/caravel.db DATABASE_URL=sqlite://data/caravel.db
@@ -12,27 +14,19 @@ ROBOT_SECRET= # Nostr private key (hex)
ROBOT_NAME= ROBOT_NAME=
ROBOT_DESCRIPTION= ROBOT_DESCRIPTION=
ROBOT_PICTURE= ROBOT_PICTURE=
ROBOT_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub
# Zooid # Zooid
ZOOID_API_URL=http://127.0.0.1:3334 ZOOID_API_URL=http://127.0.0.1:3334
ZOOID_API_SECRET=
RELAY_DOMAIN=spaces.coracle.social RELAY_DOMAIN=spaces.coracle.social
LIVEKIT_URL= LIVEKIT_URL=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
# Blossom S3 (optional; when region, bucket, access key, and secret are all set, relays with blossom enabled sync with adapter s3 and key_prefix = relay schema)
BLOSSOM_S3_ENDPOINT=
BLOSSOM_S3_REGION=
BLOSSOM_S3_BUCKET=
BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing # Billing
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...) NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production) STRIPE_SECRET_KEY= # Stripe API secret key (sk_...)
STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...)
STRIPE_PRICE_GROWTH= # Stripe price ID (price_...) for the Growth plan; required for paid plans
-13
View File
@@ -210,7 +210,6 @@ dependencies = [
"nostr-sdk", "nostr-sdk",
"nwc", "nwc",
"rand 0.8.5", "rand 0.8.5",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -1895,18 +1894,6 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
-1
View File
@@ -24,7 +24,6 @@ hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
dotenvy = "0.15.7" dotenvy = "0.15.7"
base64 = "0.22" base64 = "0.22"
regex = "1"
[dev-dependencies] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
+1 -1
View File
@@ -26,6 +26,6 @@ WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend COPY --from=build /app/target/release/backend /app/backend
EXPOSE 2892 EXPOSE 3000
CMD ["/app/backend"] CMD ["/app/backend"]
+37 -99
View File
@@ -8,7 +8,7 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
- Axum (HTTP API) - Axum (HTTP API)
- SQLx + SQLite - SQLx + SQLite
- Tokio (async runtime + workers) - Tokio (async runtime + workers)
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect, NIP-44 encryption at rest) - Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect)
## Layout ## Layout
@@ -16,82 +16,43 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
backend/ backend/
migrations/ migrations/
0001_init.sql 0001_init.sql
spec/ # Module-by-module design notes
src/ src/
main.rs # App bootstrap: load Env, build services, serve + spawn workers api.rs # Axum routes + NIP-98 auth checks
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing) billing.rs # Invoice generation + collection worker
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers infra.rs # Zooid sync worker
web.rs # HTTP response envelope + helpers main.rs # App bootstrap
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe) models.rs # DB models
models.rs # Domain models + sqlite rows repo.rs # Data access layer
query.rs # Database reads robot.rs # Nostr robot identity + DM sending
command.rs # Database writes + activity broadcast
pool.rs # SQLite pool + migrations
billing.rs # Stripe subscription reconciliation + Lightning collection worker
stripe.rs # Thin Stripe REST client
wallet.rs # NWC wallet handle (NIP-47)
bitcoin.rs # Fiat ↔ BTC/msats conversion
infra.rs # Zooid relay-sync worker
robot.rs # Nostr robot identity + DM sending
``` ```
## Configuration ## Configuration
All configuration is read from the environment by `Env::load()` at startup. **Every variable below is required**: `Env::load()` panics if any is missing or blank, and comma-separated lists must contain at least one entry. Copy `.env.template` to `.env` to get started. Environment variables:
**Server** | Variable | Description | Default |
|---|---|---|
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `3000` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
| Variable | Description | Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
| ---------------------- | ------------------------------------------------------------------- |
| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain |
| `SERVER_PORT` | API bind port |
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
**Robot identity**
| Variable | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| `ROBOT_SECRET` | Robot Nostr secret key; used for signing, NIP-44 encryption of stored NWC URLs, and NIP-98 auth |
| `ROBOT_NAME` | Robot display name (kind `0`) |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) |
| `ROBOT_WALLET` | System NWC URL used to issue and look up BOLT11 invoices |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays the robot publishes its profile and kind `10002` relay list to |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay/profile discovery |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated DM relays published as kind `10050` |
**Relay hosting (zooid / livekit)**
| Variable | Description |
| -------------------- | ------------------------------------------------------- |
| `ZOOID_API_URL` | Zooid API base URL used by the infra sync worker |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when a relay enables livekit |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid |
**Blossom S3** — sent to zooid as the S3 adapter config (with `key_prefix` = relay schema) when a relay enables blossom.
| Variable | Description |
| ----------------------- | --------------------- |
| `BLOSSOM_S3_ENDPOINT` | S3 endpoint URL |
| `BLOSSOM_S3_REGION` | S3 region |
| `BLOSSOM_S3_BUCKET` | S3 bucket name |
| `BLOSSOM_S3_ACCESS_KEY` | S3 access key ID |
| `BLOSSOM_S3_SECRET_KEY` | S3 secret access key |
**Billing (Stripe)**
| Variable | Description |
| ----------------------- | ----------------------------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
## Schema and Architecture ## Schema and Architecture
@@ -99,39 +60,16 @@ See [spec](spec) for more details
## API Routes ## API Routes
Most API routes are NIP-98 protected. All routes are NIP-98 protected.
Public exceptions: - `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `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) - `GET /tenants` — list tenants (admin)
- `POST /tenants`idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise) - `POST /tenants`create current auth pubkey as tenant
- `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant) - `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant) - `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
- `GET /relays` — list relays (admin)
- `POST /relays` — create relay (admin or relay tenant) - `POST /relays` — create relay (admin or relay tenant)
- `GET /relays/:id` — get 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) - `PUT /relays/:id` — update relay (admin or relay tenant)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant) - `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
- `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.
+1 -27
View File
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS relay (
schema TEXT NOT NULL, schema TEXT NOT NULL,
subdomain TEXT NOT NULL UNIQUE, subdomain TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL, plan TEXT NOT NULL,
stripe_subscription_item_id TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0, synced INTEGER NOT NULL DEFAULT 0,
sync_error TEXT NOT NULL DEFAULT '', sync_error TEXT NOT NULL DEFAULT '',
@@ -38,30 +39,3 @@ CREATE TABLE IF NOT EXISTS relay (
push_enabled INTEGER NOT NULL DEFAULT 1, push_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (tenant) REFERENCES tenant(pubkey) FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
); );
CREATE TABLE IF NOT EXISTS lightning_invoice (
stripe_invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
bolt11 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_id
ON relay (tenant, id);
CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
ON relay (tenant, status, plan);
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
ON activity (resource_type, resource_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
ON lightning_invoice (tenant_pubkey);
+136 -187
View File
@@ -1,259 +1,208 @@
# `pub struct Api` # `pub struct Api`
Api owns the HTTP interface: the shared application state, the router, NIP-98 authentication, and the authorization helpers. The route handlers themselves live in `crate::routes` (`routes/identity.rs`, `plans.rs`, `tenants.rs`, `relays.rs`, `invoices.rs`, `stripe.rs`) and use the response helpers in `spec/web.md`. Api manages the HTTP interface for the application
Members: Members:
- `env: Env` - configuration (see `spec/env.md`); supplies the NIP-98 host check, admin pubkeys, encryption, etc. - `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` - `query: Query`
- `command: Command` - `command: Command`
- `billing: Billing` - `billing: Billing`
- `stripe: Stripe`
- `robot: Robot`
- `infra: Infra`
Notes: Notes:
- Authentication is done using NIP-98, comparing the event's `u` tag to `env.server_host`, not the incoming request URL. - Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
- The shared `Api` is wrapped in an `Arc` and handed to every handler as `State<Arc<Api>>`. - Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
- A handler that requires an authenticated caller takes an `AuthedPubkey` extractor; handlers that omit it are anonymous. - Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
- Each handler is responsible for authorization using `require_admin` or `require_admin_or_tenant`. - 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.
- Successful responses are `{ data, code: "ok" }`; error responses are `{ error, code }`, both with an appropriate HTTP status (see `spec/web.md`).
## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self` ## `pub fn new() -> Self`
- Stores the services and a clone of `env` - Reads environment and populates members
## `pub fn router(self) -> Router` ## `pub fn router(&self) -> Result<()>`
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes - Returns an `axum::Router`
## `pub fn is_admin(&self, pubkey: &str) -> bool` --- Plan routes
- Whether `pubkey` is in `env.server_admin_pubkeys` ## `async fn list_plans(...) -> Response`
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
# Authentication
## `pub struct AuthedPubkey(pub String)`
An axum extractor (`FromRequestParts`) that authenticates a request via NIP-98 and yields the signer's pubkey. Adding it to a handler signature is what enforces "must be authenticated"; on failure the request is rejected with a `401`.
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
- Parses the `Authorization` header, which must use the `Nostr ` scheme followed by a base64-encoded NIP-98 event
- Decodes and parses the event, requires kind `27235` (`HttpAuth`), and verifies its signature
- Requires the event's `u` tag to contain `env.server_host` (skipped when `server_host` is empty)
- Intentionally does **not** enforce exact request URL/method/query matching, and does **not** validate the `payload` tag/hash, `created_at` freshness window, or a replay nonce/cache
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
- Returns the signer pubkey (hex) when all checks pass; any failure surfaces as a `401`
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible.
# Routes
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
--- Identity
## `get_identity` — `GET /identity`
- Authenticated (any signer)
- Side-effect-free: returns `{ pubkey, is_admin }`
- Clients must call `POST /tenants` before any tenant-scoped write
--- Plans
## `list_plans` — `GET /plans`
- Serves `GET /plans`
- No authentication required - No authentication required
- `data` is the list of plans from `query.list_plans` - Return `data` is a list of plan structs from `Query::list_plans`
## `get_plan` — `GET /plans/:id` ## `async fn get_plan(...) -> Response`
- Serves `GET /plans/:id`
- No authentication required - No authentication required
- `data` is the plan matching `id`; `404` `not-found` if it doesn't exist - Return `data` is a single plan struct matching `id`
- If plan does not exist, return `404` with `code=not-found`
--- Tenants --- Identity routes
## `list_tenants` — `GET /tenants` ## `async fn get_identity(...) -> Response`
- Admin only - Serves `GET /identity`
- `data` is a list of `TenantResponse` (exposes `nwc_is_set: bool` instead of `nwc_url`) - 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
## `create_tenant` — `POST /tenants` --- Tenant routes
- Authenticated (any signer); the target pubkey is the auth pubkey, no request body ## `async fn list_tenants(...) -> Response`
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
- Otherwise resolve a display name via `robot.fetch_nostr_name` (falling back to the first 8 chars of the pubkey), create a Stripe customer via `stripe.create_customer`, and create the tenant. No subscription is created yet — that happens when the first paid relay is added.
- On a unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- Always returns `200`; `data` is a `TenantResponse`
## `get_tenant` — `GET /tenants/:pubkey` - Serves `GET /tenants`
- Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants`
- Admin or matching tenant ## `async fn get_tenant(...) -> Response`
- `data` is a `TenantResponse`
## `update_tenant` — `PUT /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `query.get_tenant`
- Admin or matching tenant ## `async fn update_tenant(...) -> Response`
- Accepts an optional `nwc_url`: an empty string clears it, otherwise it is encrypted at rest via `env.encrypt`
- Updates the tenant via `command.update_tenant`
- `data` is the updated `TenantResponse`
## `list_tenant_relays` — `GET /tenants/:pubkey/relays` - Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant
- Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct
- Admin or matching tenant ## `async fn list_tenant_relays(...) -> Response`
- `data` is the tenant's relays from `query.list_relays_for_tenant`
--- Relays - Serves `GET /tenants/:pubkey/relays`
- Authorizes admin or matching tenant
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
## `list_relays` — `GET /relays` --- Relay routes
- Admin only ## `async fn list_relays(...) -> Response`
- `data` is all relays from `query.list_relays`
## `get_relay` — `GET /relays/:id` - Serves `GET /relays`
- Authorizes admin only
- Return `data` is a list of relay structs from `query.list_relays`
- `404` `not-found` if the relay doesn't exist; then admin or relay owner ## `async fn get_relay(...) -> Response`
- `data` is the relay
## `list_relay_members` — `GET /relays/:id/members` - Serves `GET /relays/:id`
- Authorizes admin or relay owner
- Return `data` is a single relay struct from `query.get_relay`
- Admin or relay owner ## `async fn create_relay(...) -> Response`
- For unsynced relays (`synced = 0`), returns an empty member list without calling zooid
- For synced relays, proxies the member list from zooid via `infra.list_relay_members`
- `data` is `{ members }`
## `create_relay` — `POST /relays` - 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`.
- Admin or the `tenant` pubkey in the request body ## `async fn update_relay(...) -> Response`
- Generates the relay `id`/`schema`, validates and normalizes the relay via `prepare_relay`, and creates it via `command.create_relay`
- Duplicate subdomain → `422` `subdomain-exists`
- `data` is the relay; HTTP `201`
## `update_relay` — `PUT /relays/:id` - 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.
- `404` if missing; then admin or relay owner ## `async fn list_relay_activity(...) -> Response`
- Applies the provided optional fields, then validates/normalizes via `prepare_relay`
- If the plan changes to one with a finite member limit and the current member count exceeds it, return `422` `member-limit-exceeded`
- Updates via `command.update_relay`; duplicate subdomain → `422` `subdomain-exists`
- `data` is the relay
## `list_relay_activity` — `GET /relays/:id/activity` - Serves `GET /relays/:id/activity`
- Authorizes admin or relay owner
- Get activity from `query.list_activity_for_relay`
- Return `data` is `{activity}`
- `404` if missing; then admin or relay owner ## `async fn deactivate_relay(...) -> Response`
- `data` is `{ activity }` from `query.list_activity_for_resource`
## `deactivate_relay` — `POST /relays/:id/deactivate` - 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
- `404` if missing; then admin or relay owner ## `async fn reactivate_relay(...) -> Response`
- If status is `delinquent`, return `400` `relay-is-delinquent`; if already `inactive`, return `400` `relay-is-inactive`
- Otherwise `command.deactivate_relay`; `data` is empty
## `reactivate_relay` — `POST /relays/:id/reactivate` - 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
- `404` if missing; then admin or relay owner --- Invoice routes
- If status is `delinquent`, return `400` `relay-is-delinquent` (a delinquent relay must be resolved through payment, not reactivated by the user); if already `active`, return `400` `relay-is-active`
- Otherwise `command.activate_relay`; `data` is empty
--- Invoices ## `async fn list_tenant_invoices(...) -> Response`
## `list_tenant_invoices` — `GET /tenants/:pubkey/invoices` - 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 }`
- Admin or matching tenant ## `async fn get_invoice(...) -> Response`
- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id`
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency }`
## `get_invoice` — `GET /invoices/:id` - 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`
- Fetches the invoice from Stripe (`404` `not-found` if it doesn't exist) ## `async fn get_invoice_bolt11(...) -> Response`
- Looks up the tenant by the invoice's `customer` (`404` if none), then authorizes admin or matching tenant
- Runs `billing.reconcile_invoice` (marks it paid if its bolt11 already settled out of band)
- `data` is the (possibly refreshed) `StripeInvoice`
## `get_lightning_invoice` — `GET /invoices/:id/bolt11` - 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 }`
- Fetches the invoice from Stripe (`404` if it doesn't exist) and the tenant by `customer` (`404` if none), then authorizes admin or matching tenant --- Stripe session route
- Runs `billing.reconcile_invoice`, then `billing.ensure_lightning_invoice` to get or (re)issue the bolt11 for the invoice's `amount_due`/`currency`
- `data` is the `LightningInvoice` (including its `bolt11`)
--- Stripe portal ## `async fn create_stripe_session(...) -> Response`
## `create_stripe_session` — `GET /tenants/:pubkey/stripe/session` - 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
- Admin or matching tenant; accepts an optional `return_url` query parameter --- Stripe webhook route
- Looks up the tenant and creates a Stripe Customer Portal session for its `stripe_customer_id`
- `data` is `{ url }` — the portal session URL
--- Stripe webhook ## `async fn stripe_webhook(...) -> Response`
## `stripe_webhook` — `POST /stripe/webhook` - 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
- No NIP-98 authentication — verified via the `Stripe-Signature` header over the raw body --- Utilities
- Reads the raw body and signature, verifies/parses the event via `stripe.get_webhook_event`, and dispatches to the handlers below
- Returns `200` on success, `400` (`webhook-error`) on verification/parse failure
# Webhook event handlers ## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
Implemented in `routes/stripe.rs`. They translate verified Stripe events into domain actions, looking the tenant up by `stripe_customer_id` and ignoring events whose customer doesn't map to a tenant. Unknown event types are ignored. - 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
## `invoice.created` Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fires immediately when a paid relay is added or a plan is upgraded). Skips `amount_due` of 0. Ensures a `LightningInvoice` exists, then in priority order: ## `require_admin(&self, authorized_pubkey: &str)`
1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through. - Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt.
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment.
## `invoice.paid` ## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
- If the tenant has `past_due_at` set, clear it (`command.clear_tenant_past_due`) and reactivate each `delinquent` relay on a paid plan via `command.activate_relay` - Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
## `invoice.payment_failed` ## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- If the tenant doesn't already have `past_due_at` set, set it (`command.set_tenant_past_due`) and DM the tenant that payment failed and their relays may be deactivated if unresolved - Validate `subdomain`
- If `plan` is free and `blossom` is enabled, return `premium-feature`
## `invoice.overdue` - If `plan` is free and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set
- Mark every `active` relay on a paid plan `delinquent` (`command.mark_relay_delinquent`) and DM the tenant that their paid relays were deactivated for non-payment - Populate missing fields using reasonable defaults
## `customer.subscription.updated`
- If the subscription status is `canceled` or `unpaid`, clear `stripe_subscription_id` (`command.clear_tenant_subscription`) and mark every `active` paid relay `delinquent`
## `customer.subscription.deleted`
- Clear `stripe_subscription_id` (`command.clear_tenant_subscription`)
## `payment_method.attached`
- Retry Stripe collection (`stripe.pay_invoice`) for every `open` invoice with `amount_due > 0`, so invoices that were due before the card was added are charged immediately
# Helpers
## `prepare_relay(api: &Api, relay: Relay) -> Result<Relay, ApiError>`
- Validates `subdomain` against the allowed pattern and a reserved list (`api`, `admin`, `internal`) → `422` `invalid-subdomain`
- Validates that `plan` matches a known plan → `422` `invalid-plan`
- If the relay enables `blossom`/`livekit` but the selected plan doesn't include it → `422` `premium-feature`
- Normalizes the boolean relay flags to sane defaults
# `TenantResponse`
The tenant shape returned by tenant endpoints. Same as `Tenant` but replaces `nwc_url` with `nwc_is_set: bool` (true when a `nwc_url` is stored) and never exposes the stored URL: `{ pubkey, nwc_is_set, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at }`.
+72 -56
View File
@@ -1,96 +1,112 @@
# `pub struct Billing` # `pub struct Billing`
Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment). Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`.
Members: Members:
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`) - `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`) - `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
- `env: Env` - used to decrypt a tenant's stored `nwc_url` - `robot: Robot`
## `pub fn new(query: Query, command: Command, env: &Env) -> Self` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)` - Reads environment and populates members
- Panics if `ROBOT_WALLET` is not a valid NWC URL
## `pub async fn start(self)` ## `pub fn start(&self)`
- Subscribes to `command.notify.subscribe()` - Subscribes to `command.notify.subscribe()`
- Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop - On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`.
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
- On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits
## `async fn reconcile_subscriptions(&self, source: &str)` ## `pub fn sync_relay_subscription(&self, activity: &Activity)`
- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors 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.
## `async fn handle_activity(&self, activity: &Activity)` - 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`.
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription` ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
## `async fn reconcile_subscription(&self, tenant: &Tenant)` - Verify the webhook signature using `self.stripe_webhook_secret`
- 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)
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe; free-only tenants have no subscription. Idempotent. ## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay. - Fetches invoices from Stripe API for the given customer
- Returns the `data` array from the Stripe response
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and the webhook handler attempts payment (see `spec/api.md`). ## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
- Build the desired state via `get_quantity_by_price_id`. - Fetches a single invoice from Stripe API by ID
- **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return. - Returns the full Stripe invoice object
- Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items.
## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>>` ## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map. - Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
- Returns the bolt11 invoice string
## `async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>>` ## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
- If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s) - Creates a Stripe Customer Portal session for the given customer
- If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None` - Returns the portal session URL
## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result<StripeSubscription>` ## `fn handle_invoice_created(&self, invoice: &Invoice)`
- Returns the existing subscription if there is one Attempts to pay a new subscription invoice. Payment priority:
- Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription`
## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)` 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.
- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription` Skip invoices with `amount_due` of 0.
## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)` ## `fn handle_invoice_paid(&self, invoice: &Invoice)`
- For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item - Look up tenant by `stripe_customer_id`
- Delete any existing item whose price no longer appears in the desired state - 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`
## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result<LightningInvoice>` ## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
- Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired - Look up tenant by `stripe_customer_id`
- Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid) - 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.
## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>` ## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
Pays a Lightning invoice from the tenant's own wallet. - 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
- Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet` ## `fn handle_subscription_updated(&self, subscription: &Subscription)`
- Pay `invoice.bolt11` from the tenant wallet
- On success, `settle_invoice(..., "nwc")`
- On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error
## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice>` - 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`
Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid. ## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
- If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged - Look up tenant by `stripe_customer_id`
- If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- On any settlement-lookup failure, log and return the invoice unchanged
## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)`
- `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins)
- `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent)
- `command.clear_tenant_nwc_error(tenant_pubkey)`
-11
View File
@@ -1,11 +0,0 @@
# `bitcoin` — fiat ↔ Bitcoin conversion
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
+47 -55
View File
@@ -9,101 +9,93 @@ Members:
Notes: Notes:
- Write methods that mutate tenants/relays are atomic and accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`, run inside a single transaction via the `with_activity` helper. - All public write methods should be atomic
- `insert_activity` builds and returns the `Activity` struct (resolving `tenant` from the resource — directly for `tenant` resources, by looking up `relay.tenant` for `relay` resources — and using `chrono::Utc::now()` for `created_at`). - All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
- After each successful commit, the `Activity` is sent on the broadcast channel. - `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`)
- The subscription/error/past-due setters and the lightning-invoice writes below intentionally do **not** log activity and write directly to the pool. - After each successful commit, sends the `Activity` on the broadcast channel
## `pub fn new(pool: SqlitePool) -> Self` ## `pub fn new(&self, pool: SqlitePool) -> Self`
- Stores the pool and creates the broadcast channel - Assigns pool to self
- Creates the broadcast channel
## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>` ## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant (writes `pubkey`, `nwc_url`, `created_at`, `stripe_customer_id`), may throw sqlite uniqueness error on pubkey - Creates tenant, may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant, pubkey)` - Logs activity as `(create_tenant, tenant_id)`
## `pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()>` ## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates the tenant's `nwc_url` - Updates tenant
- Logs activity as `(update_tenant, tenant, pubkey)` - Logs activity as `(update_tenant, tenant_id)`
## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain - Creates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(create_relay, relay, id)` - Sets relay status to `active`
- Logs activity as `(create_relay, relay_id)`
## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay (all mutable fields), resets `synced = 0` so it re-syncs to zooid; may throw sqlite uniqueness error on subdomain - Updates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay, id)` - Logs activity as `(update_relay, relay_id)`
## `pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive` and `synced = 0` - Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay, id)` - Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>` ## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent` and `synced = 0` - Sets relay status to `active`
- Logs activity as `(mark_relay_delinquent, relay, id)` - Logs activity as `(activate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub async fn activate_relay(&self, relay: &Relay) -> Result<()>` ## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
- Sets relay status to `active` and `synced = 0` - Sets `sync_error` on the relay
- Logs activity as `(activate_relay, relay, id)` - Logs activity as `(fail_relay_sync, relay_id)`
## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>` ## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 0` and `sync_error` on the relay
- Logs activity as `(fail_relay_sync, relay, id)`
## `pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, clears `sync_error` - Sets `synced = 1`, clears `sync_error`
- Logs activity as `(complete_relay_sync, relay, id)` - Logs activity as `(complete_relay_sync, relay_id)`
## `pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>` ## `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 - Sets `stripe_subscription_id` on the tenant
- Does not log activity - Does not log activity
## `pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>` ## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
- Sets `stripe_subscription_id = null` on the tenant - Sets `stripe_subscription_id = null` on the tenant
- Does not log activity - Does not log activity
## `pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>` ## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
- Sets `nwc_error` on the tenant - Sets `nwc_error` on the tenant
- Does not log activity - Does not log activity
## `pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>` ## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
- Sets `nwc_error = null` on the tenant - Sets `nwc_error = null` on the tenant
- Does not log activity - Does not log activity
## `pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>` ## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at` to the current timestamp - Sets `past_due_at` to the current timestamp
- Does not log activity - Does not log activity
## `pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>` ## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at = null` on the tenant - Sets `past_due_at = null` on the tenant
- Does not log activity - Does not log activity
## `pub async fn insert_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64) -> Result<Option<LightningInvoice>>`
- Upserts the `pending` bolt11 row for a Stripe invoice, returning the resulting row
- On conflict the stored `bolt11`/`expires_at` are replaced (this is how an expired invoice is regenerated), **except** once the invoice is `paid`: the `status = 'pending'` guard makes the update a no-op and `None` is returned so the caller can fall back to reading the settled row
- Does not log activity
## `pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()>`
- Marks a `pending` invoice `paid`, recording `paid_method = method` (`nwc` or `manual`)
- The `status = 'pending'` guard makes this idempotent and first-writer-wins: a later reconcile won't clobber the method recorded by whoever settled it first
- Does not log activity
-47
View File
@@ -1,47 +0,0 @@
# `pub struct Env`
Env is the application's configuration, loaded once at startup and cloned into every service that needs it (`Api`, `Query`, `Command`, `Billing`, `Infra`, `Robot`, `Stripe`). It is the single source of truth for environment-derived settings, and it also owns the robot nostr key used for signing, NIP-44 encryption, and NIP-98 auth.
Members (all populated from environment variables):
- `server_host: String` - from `SERVER_HOST`; also used for the NIP-98 `u` host check
- `server_port: u16` - from `SERVER_PORT`
- `server_admin_pubkeys: Vec<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
- `database_url: String` - from `DATABASE_URL`
- `robot_name: String` - from `ROBOT_NAME`
- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices
- `robot_picture: String` - from `ROBOT_PICTURE`
- `robot_description: String` - from `ROBOT_DESCRIPTION`
- `robot_outbox_relays: Vec<String>` - from `ROBOT_OUTBOX_RELAYS`
- `robot_indexer_relays: Vec<String>` - from `ROBOT_INDEXER_RELAYS`
- `robot_messaging_relays: Vec<String>` - from `ROBOT_MESSAGING_RELAYS`
- `blossom_s3_region` / `blossom_s3_bucket` / `blossom_s3_endpoint` / `blossom_s3_access_key` / `blossom_s3_secret_key: String` - from the matching `BLOSSOM_S3_*` vars
- `zooid_api_url: String` - from `ZOOID_API_URL`
- `relay_domain: String` - from `RELAY_DOMAIN`
- `livekit_url` / `livekit_api_key` / `livekit_api_secret: String` - from the matching `LIVEKIT_*` vars
- `stripe_secret_key: String` - from `STRIPE_SECRET_KEY`
- `stripe_webhook_secret: String` - from `STRIPE_WEBHOOK_SECRET`
- `stripe_price_basic: String` - Stripe price id for the Basic plan, from `STRIPE_PRICE_BASIC`
- `stripe_price_growth: String` - Stripe price id for the Growth plan, from `STRIPE_PRICE_GROWTH`
- `keys: Keys` - parsed from `ROBOT_SECRET`; used for nostr signing, NIP-44 encryption, and NIP-98 auth
## `pub fn load() -> Self`
- Reads every variable above and panics if any is missing or malformed.
- String vars must be present and non-blank (trimmed).
- The port must parse as a `u16`.
- CSV vars are split on commas, trimmed, and empties dropped; each must contain at least one entry.
- `keys` is parsed from `ROBOT_SECRET` and panics if it is not a valid nostr secret key.
## `pub fn encrypt(&self, plaintext: &str) -> Result<String>`
- NIP-44 (v2) encrypts `plaintext` to the robot's own key. Used to encrypt a tenant's `nwc_url` at rest.
## `pub fn decrypt(&self, ciphertext: &str) -> Result<String>`
- NIP-44 decrypts a value previously produced by `encrypt`.
## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String>`
- Builds a NIP-98 `Authorization` header value for an outgoing request to `url` with `method`, signed with `keys`. Used by `Infra` to authenticate requests to zooid.
+14 -39
View File
@@ -1,59 +1,34 @@
# `pub struct Infra` # `pub struct Infra`
Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance. Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`.
Members: Members:
- `env: Env` - configuration; supplies `zooid_api_url`, `relay_domain`, the `BLOSSOM_S3_*` and `LIVEKIT_*` settings, and the robot key used to sign requests - `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
## `pub fn new(query: Query, command: Command, env: &Env) -> Self` ## `pub fn new(query: Query, command: Command) -> Self`
- Stores `query`, `command`, and a clone of `env` - Reads environment and populates members
## `pub async fn start(self)` ## `pub async fn start(self)`
- Subscribes to `command.notify` - Subscribes to `command.notify`
- Runs `reconcile_relay_state("startup")` before entering the loop - Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
- On `Lagged`, runs `reconcile_relay_state("lagged")`; on `Closed`, exits
## `async fn handle_activity(&self, activity: &Activity)` ## `async fn handle_activity(&self, activity: &Activity)`
- Ignores anything that isn't a `relay` resource with activity type `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, or `fail_relay_sync` - For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- For `fail_relay_sync`, schedules a delayed retry via `schedule_relay_sync_retry` - All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
- Otherwise resolves the relay (skip if gone) and calls `sync_relay`
## `async fn reconcile_relay_state(&self, source: &str)` ## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
- Lists relays still pending sync (`query.list_relays_pending_sync`) - Calls `sync_relay` and on success calls `command.complete_relay_sync`.
- For each: `sync_relay` immediately if its `sync_error` is empty, otherwise `schedule_relay_sync_retry` - On failure calls `command.fail_relay_sync`.
## `async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str)` ## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
- Counts the relay's consecutive trailing `fail_relay_sync` activities to derive the attempt number - If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
- Computes an exponential backoff (base 30s, doubling, capped at 15 minutes); gives up after `RELAY_SYNC_RETRY_MAX_ATTEMPTS` (6) to avoid infinite retry loops - Otherwise, sends `PUT /relay/:id` to update it.
- Spawns a task that sleeps for the delay, then re-reads the relay and `sync_relay`s it (no-op if the relay is gone) - Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
## `async fn sync_relay(&self, relay: &Relay)`
- Calls `try_sync_relay`; on success `command.complete_relay_sync`, on failure `command.fail_relay_sync` with the error
## `async fn try_sync_relay(&self, relay: &Relay)`
- A relay is "new" only if it has never completed a sync (`synced != 1` and no `complete_relay_sync` activity exists). New relays are created with `POST /relay/:id`; existing relays are updated with `PATCH /relay/:id`.
- A freshly generated `secret` is included only for creation (`POST`), so updates don't rotate relay identity and we never store the secret.
- The body carries relay configuration: `host` (= `subdomain.relay_domain`), `schema`, `inactive` (true when status is `inactive` or `delinquent`), `info` (name/icon/description/pubkey), `policy`, `groups`, `management`, `blossom`, `livekit`, `push`, and hard-coded `roles`.
- When `blossom_enabled`, the blossom section uses `adapter: "s3"` with the `BLOSSOM_S3_*` settings and `s3.key_prefix` set to the relay's `schema`; otherwise it sends `{ "enabled": false }`.
- When `livekit_enabled`, the livekit section carries the `LIVEKIT_*` settings; otherwise `{ "enabled": false }`.
## `pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>>`
- `GET /relay/:id/members` from zooid; returns the `members` array
## `async fn request(&self, method, path, body)`
- Sends an authenticated request to the zooid API at `path` (relative to `env.zooid_api_url`), with a 5s timeout
- Authenticates each request with a NIP-98 header via `env.make_auth`
- Returns the response on 2xx; bails with the status and body text otherwise
+7 -15
View File
@@ -1,17 +1,9 @@
# `async fn main() -> Result<()>` # `async fn main() -> Result<()>`
- Loads `.env` (via `dotenvy`) and configures tracing/logging from the default env filter - Configures logging
- Calls `Env::load()` to read and validate all configuration - Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
- Calls `create_pool(&env.database_url)` to get a `SqlitePool` - Get an axum router from `api.router`
- Constructs the services, passing `&env` where needed: - Adds CORS middleware based on `origins`
- `Robot::new(&env).await` (publishes the robot's nostr identity) - Calls `axum::serve` with a listener
- `Stripe::new(&env)` - Spawns `infra.start`
- `Query::new(pool.clone(), &env)` - Spawns `billing.start`
- `Command::new(pool)`
- `Billing::new(query.clone(), command.clone(), &env)`
- `Infra::new(query.clone(), command.clone(), &env)`
- `Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env)`
- Builds a CORS layer restricted to the parsed `env.server_allow_origins`
- Gets the axum router from `api.router()` and applies the CORS layer
- Spawns `infra.start()` and `billing.start()` as background tasks
- Binds a `TcpListener` on `env.server_host:env.server_port` and calls `axum::serve`
+7 -18
View File
@@ -24,7 +24,6 @@ Activity is an audit log of all actions performed by a user or a worker process.
- `update_relay` - `update_relay`
- `activate_relay` - `activate_relay`
- `deactivate_relay` - `deactivate_relay`
- `mark_relay_delinquent`
- `fail_relay_sync` - `fail_relay_sync`
- `complete_relay_sync` - `complete_relay_sync`
- `resource_type` is a string identifying the resource type being modified. - `resource_type` is a string identifying the resource type being modified.
@@ -36,7 +35,7 @@ A plan represents a rate charged for relays at a given feature/usage limit. Plan
- `id` - the plan slug - `id` - the plan slug
- `name` - the plan name - `name` - the plan name
- `amount` - the plan's monthly cost in USD minor units (cents); e.g. `500` for $5/mo - `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. - `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
- `blossom` - whether blossom media hosting is available on this plan - `blossom` - whether blossom media hosting is available on this plan
- `livekit` - whether livekit audio/video calls are available on this plan - `livekit` - whether livekit audio/video calls are available on this plan
@@ -53,7 +52,7 @@ There are three plans available:
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information. Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
- `pubkey` is the nostr public key identifying the tenant - `pubkey` is the nostr public key identifying the tenant
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf; stored encrypted at rest using NIP-44 with the robot key (`ROBOT_SECRET`, via `Env::encrypt`); never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead - `nwc_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. - `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
- `created_at` unix timestamp identifying tenant creation time - `created_at` unix timestamp identifying tenant creation time
- `stripe_customer_id` a string identifying the associated stripe customer - `stripe_customer_id` a string identifying the associated stripe customer
@@ -64,12 +63,13 @@ Tenants are customers of the service, identified by a nostr `pubkey`. Public met
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique. A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - calculated based on `subdomain` (with `-` replaced by `_`) + `_` + 8 random hex chars - `id` - a random ID identifying the relay
- `tenant` - the tenant's pubkey - `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read only, same as `id`) - `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
- `subdomain` - the relay's subdomain - `subdomain` - the relay's subdomain
- `plan` - the relay's plan - `plan` - the relay's plan
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay. - `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. - `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing. - `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name - `info_name` - the relay's name
@@ -85,20 +85,9 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
Some attributes persisted to zooid via API have special handling: Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`. - The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN` - The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- The value of `inactive` is calculated based on `status` - The value of `inactive` is calculated based on `status`
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`. - The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
- The relay's `roles` are hard-coded for now. - The relay's `roles` are hard-coded for now.
# LightningInvoice
Tracks the bolt11 invoice issued on the system wallet to collect a single Stripe invoice over Lightning (either NWC auto-pay or a manual payment from the app). One row per Stripe invoice.
- `stripe_invoice_id` - the Stripe invoice this bolt11 collects (primary key)
- `tenant_pubkey` - the owning tenant
- `bolt11` - the bolt11 invoice string issued on the system wallet
- `status` - one of `pending|paid`
- `paid_method` (nullable) - how it settled, one of `nwc|manual`; set when `status` becomes `paid`
- `expires_at` - unix timestamp after which the `pending` bolt11 is considered expired and may be regenerated
- `created_at` / `updated_at` - unix timestamps
+5 -6
View File
@@ -1,15 +1,14 @@
# `pub async fn create_pool(database_url: &str) -> Result<SqlitePool>` # `pub async fn create_pool() -> Result<SqlitePool>`
Creates and returns a sqlite connection pool. Creates and returns a sqlite connection pool.
Notes: Notes:
- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice` - Database table names are singular: `activity`, `tenant`, `relay`
Steps: Steps:
- Normalizes `database_url`: a relative `sqlite://` path is resolved under the crate manifest directory (`CARGO_MANIFEST_DIR`); absolute paths and `:memory:` are left as-is - Reads `DATABASE_URL` from environment
- Ensures any directory referred to in the (normalized) URL exists - Ensures that any directories referred to in `DATABASE_URL` exist
- Opens the pool with `create_if_missing` enabled - Initializes the sqlx pool
- Enables WAL journaling (`PRAGMA journal_mode = WAL`)
- Runs migrations found in the `migrations` directory - Runs migrations found in the `migrations` directory
+20 -40
View File
@@ -5,65 +5,45 @@ Query reads from the database.
Members: Members:
- `pool: SqlitePool` - a sqlite connection pool - `pool: SqlitePool` - a sqlite connection pool
- `env: Env` - configuration; used to fill in plan `stripe_price_id`s from `STRIPE_PRICE_*`
## `pub fn new(pool: SqlitePool, env: &Env) -> Self` ## `pub fn new(&self, pool: SqlitePool) -> Self`
- Stores the pool and a clone of `env` - Assigns pool to self
## `pub fn list_plans(&self) -> Vec<Plan>` ## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- The `basic`/`growth` `stripe_price_id`s come from `env` (`stripe_price_basic` / `stripe_price_growth`); `free` has none
- This is the source of truth for plan metadata exposed via API
## `pub fn get_plan(&self, plan_id: &str) -> Option<Plan>`
- Returns the plan matching `plan_id`, if any
## `pub fn is_paid_plan(&self, plan_id: &str) -> bool`
- Returns whether `plan_id` is a known plan with `amount > 0`
## `pub async fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants - Returns all tenants
## `pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>>` ## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
- Returns the matching tenant, or `None` if not found - Returns matching tenant
## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>>` ## `pub fn list_plans() -> Vec<Plan>`
- Returns the tenant matching the given `stripe_customer_id`, or `None` - 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 async fn list_relays(&self) -> Result<Vec<Relay>>` ## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays - Returns all relays
## `pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>>` ## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays where `synced = 0` or `sync_error` is non-empty
- Used by `Infra` to reconcile relays that still need to be pushed to zooid
## `pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant - Returns all relays belonging to the given tenant
## `pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>>` ## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
- Returns the matching relay, or `None` if not found - Returns matching relay
## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result<Option<LightningInvoice>>` ## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
- Returns the `lightning_invoice` row for the given Stripe invoice, or `None` - Returns the tenant matching the given `stripe_customer_id`
## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>>` ## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
- Returns all activity where `resource_id = resource_id` - 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 - Ordered newest-first
## `pub async fn get_latest_activity_for_resource_and_type(&self, resource_id: &str, activity_type: &str) -> Result<Option<Activity>>`
- Returns the most recent activity for `resource_id` with the given `activity_type`, or `None`
- Used by `Infra` to decide whether a relay has ever completed a sync
+15 -22
View File
@@ -1,32 +1,25 @@
# `pub struct Robot` # `pub struct Robot`
Robot is the nostr identity that acts on behalf of the application — it publishes the app's profile/relay lists and sends DMs to tenants. It signs with the robot key in `env.keys` and builds nostr clients on demand from the relay lists in `env`. Robot is a nostr identity which acts on behalf of the application.
Members: Members:
- `env: Env` - configuration; supplies the robot key and the outbox/indexer/messaging relay lists and profile metadata - `secret: String` - a nostr secret key, from `ROBOT_SECRET`
- `outbox_cache` / `dm_cache` - per-recipient caches (5 minute TTL) of discovered outbox and messaging relays - `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 async fn new(env: &Env) -> Result<Self>` ## `pub fn new() -> Self`
- Stores a clone of `env` and initializes the caches - Reads environment and populates members. Relay urls should be split and normalized.
- Calls `publish_identity`, which publishes a `kind 0` profile and a `kind 10002` relay list (the `ROBOT_OUTBOX_RELAYS`, as `r` tags) to the outbox relays, and a `kind 10050` DM relay list (the `ROBOT_MESSAGING_RELAYS`, as `relay` tags) via the indexer relays - 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<()>` ## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
- Resolves the recipient's outbox relays (`fetch_outbox_relays`), then their messaging relays from those outbox relays (`fetch_messaging_relays_from_outbox`) - Fetches recipient's outbox relays from `indexer_relays` (cached)
- Sends a NIP-17 private message to the recipient via their messaging relays - Fetches recipient's messaging relays from their outbox relays (cached)
- Errors if no outbox or messaging relays are found - Sends DM to recipient via their messaging relays
- If no outbox/messaging relays are found, return an error
## `pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String>`
- Fetches the recipient's `kind 0` metadata from the indexer relays and returns its `display_name` (falling back to `name`), trimmed and non-empty
- Returns `None` on any failure — used to derive a Stripe customer display name
## `async fn fetch_outbox_relays(&self, recipient: &str) -> Result<Vec<String>>`
- Returns the `r` tags from the recipient's latest `kind 10002` event, fetched from the indexer relays; cached for 5 minutes
## `async fn fetch_messaging_relays_from_outbox(&self, recipient: &str, outbox_relays: &[String]) -> Result<Vec<String>>`
- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes
-98
View File
@@ -1,98 +0,0 @@
# `pub struct Stripe`
A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.md`.
Members:
- `env: Env` - configuration; supplies the Stripe secret key (bearer token + idempotency HMAC key) and the webhook signing secret
- `http: reqwest::Client`
All requests authenticate with `env.stripe_secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with the secret key. Reconcile-to-desired-state writes (e.g. setting an item quantity, deleting/canceling) intentionally omit the idempotency key, since re-applying the same target is a no-op.
On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line.
## `pub fn new(env: &Env) -> Self`
Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call.
## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String>`
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
- Idempotent on `tenant_pubkey`
- Returns the new customer id
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<StripeSubscription>>`
- `GET /v1/subscriptions/:id`
- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the parsed `StripeSubscription`
## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap<String, i64>) -> Result<StripeSubscription>`
- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry
- Idempotent on the customer and the `(price, quantity)` set
- Returns the created `StripeSubscription` (including its items)
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items`
- Idempotent on `(subscription_id, price_id)`
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items/:id` with `quantity`
- No idempotency key (reconcile-to-target write)
## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>`
- `DELETE /v1/subscription_items/:id`
## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>`
- `DELETE /v1/subscriptions/:id`
## `pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>>`
- `GET /v1/invoices?customer=…`
- Returns the parsed `data` array
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>>`
- `GET /v1/invoices/:id`
- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice`
## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>`
- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method)
- Idempotent on `invoice_id`
## `pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()>`
- `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe
- Idempotent on `invoice_id` (under a distinct key from `pay_invoice`)
## `pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool>`
- `GET /v1/payment_methods?customer=…&type=card`
- Returns whether the customer has at least one card on file
## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url`
- Returns the Customer Portal session URL
## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent>`
Verifies the `Stripe-Signature` header against `env.stripe_webhook_secret` and parses the body.
- Parse `t=` (timestamp) and `v1=` (signature) from the header
- Compute `HMAC-SHA256(secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch
- Error if the timestamp is more than 300 seconds from now
- Returns the deserialized `StripeWebhookEvent`
# Typed results
- `StripeWebhookEvent { event_type: String, data: StripeWebhookEventData }`, `StripeWebhookEventData { object: serde_json::Value }` — the verified, parsed webhook event (`event_type` deserializes from the JSON `type` field)
- `StripeSubscription { id, status, items: Vec<StripeSubscriptionItem> }` (`items` flattened from Stripe's `{ data: [...] }` list)
- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent)
- `StripePrice { id }`
- `StripeInvoice { id, customer, status, amount_due, currency }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
-23
View File
@@ -1,23 +0,0 @@
# `pub struct Wallet`
A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. Each operation opens a fresh NWC connection and shuts it down afterwards.
Member:
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
## `pub fn from_url(url: &str) -> Result<Self>`
Parses an `nostr+walletconnect://` URI.
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result<String>`
Issues a bolt11 invoice for `amount_msats` with the given `description` and expiry, and returns the bolt11 string.
## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
Pays a bolt11 invoice.
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
-39
View File
@@ -1,39 +0,0 @@
# `web` — HTTP response helpers
General-purpose helpers shared across the route handlers in `spec/api.md` (implemented under `src/routes/`). They standardize the success/error envelope and a couple of small utilities.
Successful responses are `{ data, code: "ok" }` with an appropriate HTTP status. Error responses are `{ error, code }` with an appropriate HTTP status, where `code` is a short machine-readable string (e.g. `subdomain-exists`) and `error` is a human-readable message.
## `pub struct ApiError(pub Box<Response>)`
A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From<Response>`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`.
## `pub type ApiResult = Result<Response, ApiError>`
The return type of every route handler. Success builders return `ApiResult` so they sit at the end of a handler without an `Ok(..)` wrap; error builders return `ApiError`.
## Response bodies
- `DataResponse<T> { data: T, code: "ok" }` - the success envelope
- `ErrorResponse { error: String, code: String }` - the error envelope
## Success builders (return `ApiResult`)
- `res<T>(status, data)` - `{ data, code: "ok" }` with `status`
- `ok<T>(data)` - `res(200, data)`
- `created<T>(data)` - `res(201, data)`
## Error builders (return `ApiError`)
- `err(status, code, message)` - the base `{ error, code }` builder
- `unauthorized(reason)` - `401`, `code = "unauthorized"`
- `forbidden(message)` - `403`, `code = "forbidden"`
- `not_found(message)` - `404`, `code = "not-found"`
- `bad_request(code, message)` - `400` with the given `code`
- `unprocessable(code, message)` - `422` with the given `code`
- `internal(reason)` - `500`, `code = "internal"`
## Utilities
- `parse_bool_default(value: i64, default: i64) -> i64` - returns `value` if it is `0` or `1`, otherwise `default`. Used to normalize boolean-ish relay flags.
- `map_unique_error(err: &anyhow::Error) -> Option<&'static str>` - recognizes sqlite UNIQUE constraint violations so callers can translate them into `422`s instead of `500`s. Returns `pubkey-exists` or `subdomain-exists` when the violated column message matches, else `None`.
+871 -159
View File
File diff suppressed because it is too large Load Diff
+970 -266
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
use anyhow::{Result, anyhow};
pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
let price = get_bitcoin_price(&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)
}
+247 -218
View File
@@ -3,8 +3,7 @@ use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::models::{ use crate::models::{
Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -19,8 +18,6 @@ impl Command {
Self { pool, notify } Self { pool, notify }
} }
// Activity
async fn insert_activity( async fn insert_activity(
tx: &mut Transaction<'_, Sqlite>, tx: &mut Transaction<'_, Sqlite>,
activity_type: &str, activity_type: &str,
@@ -35,7 +32,7 @@ impl Command {
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await? .await?
} }
_ => anyhow::bail!("unknown resource_type: {resource_type}"), _ => anyhow::bail!("unknown resource_type: {}", resource_type),
}; };
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
@@ -64,55 +61,218 @@ impl Command {
}) })
} }
/// Run `f` inside a transaction, record an activity row, commit, and broadcast. fn emit(&self, activity: Activity) {
async fn with_activity<F>(
&self,
activity_type: &str,
resource_type: &str,
resource_id: &str,
f: F,
) -> Result<()>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<()>,
{
let mut tx = self.pool.begin().await?;
f(&mut tx).await?;
let activity =
Self::insert_activity(&mut tx, activity_type, resource_type, resource_id).await?;
tx.commit().await?;
let _ = self.notify.send(activity); let _ = self.notify.send(activity);
}
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
if tenant.stripe_customer_id.trim().is_empty() {
anyhow::bail!("stripe_customer_id is required");
}
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.bind(&tenant.stripe_customer_id)
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
self.emit(activity);
Ok(()) Ok(())
} }
// Tenants pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> { sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
self.with_activity("create_tenant", "tenant", &tenant.pubkey, async |tx| {
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url) .bind(&tenant.nwc_url)
.bind(tenant.created_at) .bind(&tenant.pubkey)
.bind(&tenant.stripe_customer_id) .execute(&mut *tx)
.execute(&mut **tx)
.await?; .await?;
Ok(())
}) let activity =
.await Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
} }
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> { pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
self.with_activity("update_tenant", "tenant", &tenant.pubkey, async |tx| { let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url) sqlx::query(
.bind(&tenant.pubkey) "INSERT INTO relay (
.execute(&mut **tx) id, tenant, schema, subdomain, plan, status, sync_error,
.await?; info_name, info_icon, info_description,
Ok(()) policy_public_join, policy_strip_signatures,
}) groups_enabled, management_enabled, blossom_enabled,
.await livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, "create_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
.await
}
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "deactivate_relay")
.await
}
async fn set_relay_status(
&self,
relay_id: &str,
status: &str,
activity_type: &str,
) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = ? WHERE id = ?")
.bind(status)
.bind(relay_id)
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay")
.await
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&mut *tx)
.await?;
let activity =
Self::insert_activity(&mut tx, "complete_relay_sync", "relay", relay_id).await?;
tx.commit().await?;
self.emit(activity);
Ok(())
}
pub async fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET stripe_subscription_item_id = NULL WHERE id = ?")
.bind(relay_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn set_relay_subscription_item(
&self,
relay_id: &str,
stripe_subscription_item_id: &str,
) -> Result<()> {
sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?")
.bind(stripe_subscription_item_id)
.bind(relay_id)
.execute(&self.pool)
.await?;
Ok(())
} }
pub async fn set_tenant_subscription( pub async fn set_tenant_subscription(
@@ -170,188 +330,57 @@ impl Command {
.await?; .await?;
Ok(()) Ok(())
} }
}
// Relays #[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
pub async fn create_relay(&self, relay: &Relay) -> Result<()> { async fn test_pool() -> SqlitePool {
self.with_activity("create_relay", "relay", &relay.id, async |tx| { let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
sqlx::query( .expect("valid sqlite memory url")
"INSERT INTO relay ( .create_if_missing(true);
id, tenant, schema, subdomain, plan, status, synced, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn update_relay(&self, relay: &Relay) -> Result<()> { let pool = SqlitePoolOptions::new()
self.with_activity("update_relay", "relay", &relay.id, async |tx| { .max_connections(1)
sqlx::query( .connect_with(connect_options)
"UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
self.set_relay_status(&relay.id, RELAY_STATUS_INACTIVE, "deactivate_relay")
.await .await
} .expect("connect sqlite memory db");
pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()> { sqlx::migrate!("./migrations")
self.set_relay_status(&relay.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent") .run(&pool)
.await .await
.expect("run migrations");
pool
} }
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> { #[tokio::test]
self.set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay") async fn create_tenant_rejects_empty_stripe_customer_id() {
let pool = test_pool().await;
let command = Command::new(pool);
let tenant = Tenant {
pubkey: "tenant_pubkey".to_string(),
nwc_url: String::new(),
nwc_error: None,
created_at: 0,
stripe_customer_id: " ".to_string(),
stripe_subscription_id: None,
past_due_at: None,
};
let err = command
.create_tenant(&tenant)
.await .await
} .expect_err("empty customer id must be rejected");
async fn set_relay_status( assert!(
&self, err.to_string().contains("stripe_customer_id is required"),
relay_id: &str, "unexpected error: {err}"
status: &str, );
activity_type: &str,
) -> Result<()> {
self.with_activity(activity_type, "relay", relay_id, async |tx| {
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
.bind(status)
.bind(relay_id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
self.with_activity("fail_relay_sync", "relay", &relay.id, async |tx| {
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()> {
self.with_activity("complete_relay_sync", "relay", relay_id, async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
// Invoices
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On
/// conflict the stored bolt11/expiry are replaced — this is how an expired
/// invoice is regenerated — except once the invoice is paid, when the
/// `status = 'pending'` guard makes the update a no-op and `None` is
/// returned so the caller can fall back to reading the settled row.
pub async fn insert_lightning_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
bolt11: &str,
expires_at: i64,
) -> Result<Option<LightningInvoice>> {
let now = chrono::Utc::now().timestamp();
let row = sqlx::query_as::<_, LightningInvoice>(
"INSERT INTO lightning_invoice
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
bolt11 = excluded.bolt11,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
WHERE status = 'pending'
RETURNING *",
)
.bind(stripe_invoice_id)
.bind(tenant_pubkey)
.bind(bolt11)
.bind(expires_at)
.bind(now)
.bind(now)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
/// Mark a pending invoice paid, recording which method settled it. The
/// `status = 'pending'` guard makes this idempotent and first-writer-wins:
/// a later reconcile won't clobber the method recorded by whoever settled
/// it first.
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE lightning_invoice
SET status = 'paid', paid_method = ?, updated_at = ?
WHERE stripe_invoice_id = ? AND status = 'pending'",
)
.bind(method)
.bind(now)
.bind(stripe_invoice_id)
.execute(&self.pool)
.await?;
Ok(())
} }
} }
-126
View File
@@ -1,126 +0,0 @@
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
#[derive(Clone)]
pub struct Env {
pub server_host: String,
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
pub database_url: String,
pub robot_name: String,
pub robot_wallet: String,
pub robot_picture: String,
pub robot_description: String,
pub robot_outbox_relays: Vec<String>,
pub robot_indexer_relays: Vec<String>,
pub robot_messaging_relays: Vec<String>,
pub blossom_s3_region: String,
pub blossom_s3_bucket: String,
pub blossom_s3_endpoint: String,
pub blossom_s3_access_key: String,
pub blossom_s3_secret_key: String,
pub zooid_api_url: String,
pub relay_domain: String,
pub livekit_url: String,
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
pub stripe_webhook_secret: String,
pub stripe_price_basic: String,
pub stripe_price_growth: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
pub fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_host: require_str("SERVER_HOST"),
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
database_url: require_str("DATABASE_URL"),
robot_name: require_str("ROBOT_NAME"),
robot_wallet: require_str("ROBOT_WALLET"),
robot_picture: require_str("ROBOT_PICTURE"),
robot_description: require_str("ROBOT_DESCRIPTION"),
robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"),
robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"),
robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"),
blossom_s3_region: require_str("BLOSSOM_S3_REGION"),
blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"),
blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"),
blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"),
blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"),
zooid_api_url: require_str("ZOOID_API_URL"),
relay_domain: require_str("RELAY_DOMAIN"),
livekit_url: require_str("LIVEKIT_URL"),
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
keys,
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
nip44::encrypt(
self.keys.secret_key(),
&self.keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(&self, ciphertext: &str) -> Result<String> {
nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&self.keys)
.await?;
Ok(auth)
}
}
fn require_str(key: &str) -> String {
let v = std::env::var(key)
.unwrap_or_else(|_| panic!("{key} is required"))
.trim()
.to_string();
if v.is_empty() {
panic!("{key} is required")
}
v
}
fn require_u16(key: &str) -> u16 {
require_str(key)
.parse()
.unwrap_or_else(|_| panic!("{key} is invalid"))
}
fn require_csv(key: &str) -> Vec<String> {
let v: Vec<String> = std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if v.is_empty() {
panic!("{key} is required");
}
v
}
+92 -215
View File
@@ -1,27 +1,37 @@
use anyhow::Result; use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command; use crate::command::Command;
use crate::env::Env; use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query; use crate::query::Query;
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
#[derive(Clone)] #[derive(Clone)]
pub struct Infra { pub struct Infra {
env: Env, api_url: String,
relay_domain: String,
livekit_url: String,
livekit_api_key: String,
livekit_api_secret: String,
api_secret: String,
query: Query, query: Query,
command: Command, command: Command,
} }
impl Infra { impl Infra {
pub fn new(query: Query, command: Command, env: &Env) -> Self { pub fn new(query: Query, command: Command) -> Self {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
Self { Self {
env: env.clone(), api_url,
relay_domain,
livekit_url,
livekit_api_key,
livekit_api_secret,
api_secret,
query, query,
command, command,
} }
@@ -30,10 +40,6 @@ impl Infra {
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(activity) => { Ok(activity) => {
@@ -43,10 +49,6 @@ impl Infra {
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged"); 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, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
} }
@@ -54,108 +56,22 @@ impl Infra {
} }
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = matches!( let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
);
if activity.resource_type != "relay" || !needs_sync { if needs_sync {
return Ok(()); let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
} return Ok(());
};
if activity.activity_type == "fail_relay_sync" { let is_new = relay.synced == 0;
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?; self.sync_and_report(&relay, is_new).await;
return Ok(());
}
let Some(relay) = self.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 = self.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(()) Ok(())
} }
async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str) -> Result<()> { async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) {
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> { match self.sync_relay(relay, is_new).await {
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 = self.query.list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count();
let Some(delay) = get_retry_delay(consecutive_failures) else {
tracing::warn!(
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 infra.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(()) => { Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded"); tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = self.command.complete_relay_sync(&relay.id).await { if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
@@ -171,21 +87,42 @@ impl Infra {
} }
} }
async fn try_sync_relay(&self, relay: &Relay) -> Result<()> { async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
// A relay is "new" (POST with a freshly generated secret) only if it has let keys = Keys::parse(&self.api_secret)?;
// never completed a sync. `synced == 1` short-circuits the activity lookup; let server_url = Url::parse(url)?;
// otherwise check the activity history so that a re-sync after an update let auth = HttpData::new(server_url, method)
// (which resets `synced` to 0) PATCHes instead of clobbering the secret. .to_authorization(&keys)
let is_new = relay.synced != 1 .await?;
&& self Ok(auth)
.query }
.get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
let mut body = serde_json::json!({ async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> {
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain), 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, "schema": relay.schema,
"secret": secret,
"inactive": relay.status == RELAY_STATUS_INACTIVE "inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT, || relay.status == RELAY_STATUS_DELINQUENT,
"info": { "info": {
@@ -200,32 +137,8 @@ impl Infra {
}, },
"groups": { "enabled": relay.groups_enabled == 1 }, "groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 }, "management": { "enabled": relay.management_enabled == 1 },
"blossom": if relay.blossom_enabled == 1 { "blossom": { "enabled": relay.blossom_enabled == 1 },
serde_json::json!({ "livekit": livekit,
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": self.env.blossom_s3_endpoint,
"region": self.env.blossom_s3_region,
"bucket": self.env.blossom_s3_bucket,
"access_key": self.env.blossom_s3_access_key,
"secret_key": self.env.blossom_s3_secret_key,
"key_prefix": relay.schema,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.env.livekit_url,
"api_key": self.env.livekit_api_key,
"api_secret": self.env.livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 }, "push": { "enabled": relay.push_enabled == 1 },
"roles": { "roles": {
"admin": { "can_manage": true, "can_invite": true }, "admin": { "can_manage": true, "can_invite": true },
@@ -233,74 +146,38 @@ impl Infra {
}, },
}); });
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side. let response = if is_new {
if is_new && let Some(obj) = body.as_object_mut() { let url = format!("{}/relay/{}", base, relay.id);
obj.insert( let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
"secret".to_string(), client
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()), .post(&url)
); .header("Authorization", auth)
} .json(&body)
.send()
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH }; .await?
self.request(method, &format!("relay/{}", relay.id), Some(&body)) } else {
.await?; let url = format!("{}/relay/{}", base, relay.id);
Ok(()) let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
} client
.put(&url)
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> { .header("Authorization", auth)
#[derive(serde::Deserialize)] .json(&body)
struct MembersResponse { .send()
members: Vec<String>, .await?
}
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 = self.env.zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = self.env.make_auth(&url, method).await?;
let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::PATCH => reqwest::Method::PATCH,
}; };
let mut req = client
.request(reqwest_method, &url)
.header("Authorization", auth);
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid {method} {path} returned {status}: {text}"); anyhow::bail!("zooid sync returned {}: {}", status, body)
} }
Ok(response) Ok(())
} }
} }
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
}
-6
View File
@@ -1,14 +1,8 @@
pub mod api; pub mod api;
pub mod billing; pub mod billing;
pub mod bitcoin;
pub mod command; pub mod command;
pub mod env;
pub mod infra; pub mod infra;
pub mod models; pub mod models;
pub mod pool; pub mod pool;
pub mod query; pub mod query;
pub mod robot; pub mod robot;
pub mod routes;
pub mod stripe;
pub mod wallet;
pub mod web;
+29 -26
View File
@@ -1,17 +1,11 @@
mod api; mod api;
mod billing; mod billing;
mod bitcoin;
mod command; mod command;
mod env;
mod infra; mod infra;
mod models; mod models;
mod pool;
mod query; mod query;
mod pool;
mod robot; mod robot;
mod routes;
mod stripe;
mod wallet;
mod web;
use anyhow::Result; use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -21,11 +15,9 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::api::Api; use crate::api::Api;
use crate::billing::Billing; use crate::billing::Billing;
use crate::command::Command; use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra; use crate::infra::Infra;
use crate::query::Query; use crate::query::Query;
use crate::robot::Robot; use crate::robot::Robot;
use crate::stripe::Stripe;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -36,23 +28,35 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let env = Env::load(); let pool = pool::create_pool().await?;
let robot = Robot::new().await?;
let pool = pool::create_pool(&env.database_url).await?; let query = Query::new(pool.clone());
let robot = Robot::new(&env).await?;
let stripe = Stripe::new(&env);
let query = Query::new(pool.clone(), &env);
let command = Command::new(pool); let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), &env); let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone(), &env); let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env); let api = Api::new(query, command, billing.clone());
let parsed = env let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
.server_allow_origins let port: u16 = std::env::var("PORT")
.iter() .ok()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok()) .and_then(|v| v.parse().ok())
.collect::<Vec<_>>(); .unwrap_or(3000);
let cors = CorsLayer::new().allow_origin(AllowOrigin::list(parsed)); let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let cors = if origins.is_empty() {
CorsLayer::permissive()
} else {
let parsed = origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
CorsLayer::new().allow_origin(AllowOrigin::list(parsed))
};
let app = api.router().layer(cors); let app = api.router().layer(cors);
@@ -64,8 +68,7 @@ async fn main() -> Result<()> {
billing.start().await; billing.start().await;
}); });
let listener = let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }
+2 -38
View File
@@ -25,7 +25,7 @@ pub struct Plan {
pub stripe_price_id: Option<String>, pub stripe_price_id: Option<String>,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Tenant { pub struct Tenant {
pub pubkey: String, pub pubkey: String,
pub nwc_url: String, pub nwc_url: String,
@@ -36,18 +36,6 @@ pub struct Tenant {
pub past_due_at: Option<i64>, pub past_due_at: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct LightningInvoice {
pub stripe_invoice_id: String,
pub tenant_pubkey: String,
pub bolt11: String,
pub status: String,
pub paid_method: Option<String>,
pub expires_at: i64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Relay { pub struct Relay {
pub id: String, pub id: String,
@@ -55,6 +43,7 @@ pub struct Relay {
pub schema: String, pub schema: String,
pub subdomain: String, pub subdomain: String,
pub plan: String, pub plan: String,
pub stripe_subscription_item_id: Option<String>,
pub status: String, pub status: String,
pub sync_error: String, pub sync_error: String,
pub info_name: String, pub info_name: String,
@@ -69,28 +58,3 @@ pub struct Relay {
pub push_enabled: i64, pub push_enabled: i64,
pub synced: i64, pub synced: i64,
} }
impl Default for Relay {
fn default() -> Self {
Self {
id: String::new(),
tenant: String::new(),
schema: String::new(),
subdomain: String::new(),
plan: String::new(),
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: String::new(),
info_icon: String::new(),
info_description: String::new(),
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
synced: 0,
}
}
}
+6 -3
View File
@@ -7,8 +7,10 @@ use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions}, sqlite::{SqliteConnectOptions, SqlitePoolOptions},
}; };
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> { pub async fn create_pool() -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url); 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://") if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty() && !path.is_empty()
@@ -19,7 +21,8 @@ pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); let connect_options =
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
+95 -112
View File
@@ -1,38 +1,42 @@
use anyhow::Result; use anyhow::Result;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::env::Env; use crate::models::{Activity, Plan, Relay, Tenant};
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
}
fn select_relay(tail: &str) -> String {
format!("SELECT * FROM relay {tail}")
}
fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
#[derive(Clone)] #[derive(Clone)]
pub struct Query { pub struct Query {
pool: SqlitePool, pool: SqlitePool,
env: Env,
} }
impl Query { impl Query {
pub fn new(pool: SqlitePool, env: &Env) -> Self { pub fn new(pool: SqlitePool) -> Self {
Self { Self { pool }
pool,
env: env.clone(),
}
} }
// Plans 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)
}
pub fn list_plans(&self) -> Vec<Plan> { pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
WHERE pubkey = ?",
)
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub fn list_plans() -> Vec<Plan> {
vec![ vec![
Plan { Plan {
id: "free".to_string(), id: "free".to_string(),
@@ -50,7 +54,7 @@ impl Query {
members: Some(100), members: Some(100),
blossom: true, blossom: true,
livekit: true, livekit: true,
stripe_price_id: Some(self.env.stripe_price_basic.clone()), stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
}, },
Plan { Plan {
id: "growth".to_string(), id: "growth".to_string(),
@@ -59,33 +63,59 @@ impl Query {
members: None, members: None,
blossom: true, blossom: true,
livekit: true, livekit: true,
stripe_price_id: Some(self.env.stripe_price_growth.clone()), stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
}, },
] ]
} }
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> { pub async fn list_relays(&self) -> Result<Vec<Relay>> {
self.list_plans().into_iter().find(|p| p.id == plan_id) let rows = sqlx::query_as::<_, Relay>(
} "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
pub fn is_paid_plan(&self, plan_id: &str) -> bool { info_name, info_icon, info_description,
self.get_plan(plan_id).is_some_and(|p| p.amount > 0) policy_public_join, policy_strip_signatures,
} groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
// Tenants FROM relay
ORDER BY id",
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> { )
let rows = sqlx::query_as::<_, Tenant>(&select_tenant("")) .fetch_all(&self.pool)
.fetch_all(&self.pool) .await?;
.await?;
Ok(rows) Ok(rows)
} }
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> { pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?")) let rows = sqlx::query_as::<_, Relay>(
.bind(pubkey) "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
.fetch_optional(&self.pool) status, sync_error,
.await?; info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE tenant = ?
ORDER BY id",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row) Ok(row)
} }
@@ -93,84 +123,37 @@ impl Query {
&self, &self,
stripe_customer_id: &str, stripe_customer_id: &str,
) -> Result<Option<Tenant>> { ) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE stripe_customer_id = ?")) let row = sqlx::query_as::<_, Tenant>(
.bind(stripe_customer_id) "SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
.fetch_optional(&self.pool) FROM tenant
.await?; WHERE stripe_customer_id = ?",
Ok(row)
}
// Relays
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(
"WHERE synced = 0 OR TRIM(sync_error) != ''",
))
.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_relay("WHERE tenant = ?"))
.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_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Invoices
pub async fn get_lightning_invoice(
&self,
stripe_invoice_id: &str,
) -> Result<Option<LightningInvoice>> {
let row = sqlx::query_as::<_, LightningInvoice>(
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
) )
.bind(stripe_invoice_id) .bind(stripe_customer_id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row) Ok(row)
} }
// Activity 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_resource(&self, resource_id: &str) -> Result<Vec<Activity>> { pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC")) let rows = sqlx::query_as::<_, Activity>(
.bind(resource_id) "SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows) Ok(rows)
} }
pub async fn get_latest_activity_for_resource_and_type(
&self,
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
let row = sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
} }
+127 -51
View File
@@ -5,11 +5,15 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::env::Env;
#[derive(Clone)] #[derive(Clone)]
pub struct Robot { pub struct Robot {
env: Env, 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>>>, outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>, dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
} }
@@ -21,61 +25,84 @@ struct CacheEntry {
} }
impl Robot { impl Robot {
pub async fn new(env: &Env) -> Result<Self> { 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 { let robot = Self {
env: env.clone(), secret,
name,
description,
picture,
outbox_client,
indexer_client,
messaging_client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}; };
robot.publish_identity().await?; robot
.publish_identity(&outbox_relays, &messaging_relays)
.await?;
Ok(robot) Ok(robot)
} }
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(self.env.keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn publish_identity( async fn publish_identity(
&self, &self,
outbox_relays: &[String],
messaging_relays: &[String],
) -> Result<()> { ) -> Result<()> {
let mut metadata = Metadata::new(); let mut metadata = Metadata::new();
if !self.env.robot_name.is_empty() { if !self.name.is_empty() {
metadata = metadata.name(&self.env.robot_name); metadata = metadata.name(&self.name);
} }
if !self.env.robot_description.is_empty() { if !self.description.is_empty() {
metadata = metadata.about(&self.env.robot_description); metadata = metadata.about(&self.description);
} }
if !self.env.robot_picture.is_empty() { if !self.picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?); metadata = metadata.picture(Url::parse(&self.picture)?);
} }
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?; self.outbox_client
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata)) .send_event_builder(EventBuilder::metadata(&metadata))
.await?; .await?;
let outbox_tags = self.env.robot_outbox_relays let outbox_tags = outbox_relays
.iter() .iter()
.map(|r| Tag::parse(["r", r.as_str()])) .map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
outbox_client self.outbox_client
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags)) .send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?; .await?;
let messaging_tags = self.env.robot_messaging_relays let messaging_tags = messaging_relays
.iter() .iter()
.map(|r| Tag::parse(["relay", r.as_str()])) .map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
indexer_client self.indexer_client
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags)) .send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
.await?; .await?;
@@ -96,8 +123,14 @@ impl Robot {
} }
let recipient_pubkey = PublicKey::parse(recipient)?; let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.make_client(&dm_relays).await?; let client = self.messaging_client.clone();
client.send_private_msg(recipient_pubkey, message, []).await?; for relay in dm_relays {
let _ = client.add_relay(relay).await;
}
client.connect().await;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
Ok(()) Ok(())
} }
@@ -108,8 +141,10 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?; let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002)); let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let client = self.make_client(&self.env.robot_indexer_relays).await?; let events = self
let events = client.fetch_events(filter, Duration::from_secs(5)).await?; .indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await?;
let mut relays = Vec::new(); let mut relays = Vec::new();
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) { if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
@@ -125,22 +160,6 @@ impl Robot {
Ok(relays) Ok(relays)
} }
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
Some(name)
}
async fn fetch_messaging_relays_from_outbox( async fn fetch_messaging_relays_from_outbox(
&self, &self,
recipient: &str, recipient: &str,
@@ -151,7 +170,13 @@ impl Robot {
} }
let pubkey = PublicKey::parse(recipient)?; let pubkey = PublicKey::parse(recipient)?;
let client = self.make_client(outbox_relays).await?; let keys = Keys::parse(&self.secret)?;
let client = Client::new(keys);
for relay in outbox_relays {
client.add_relay(relay).await?;
}
client.connect().await;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050)); let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
let events = client.fetch_events(filter, Duration::from_secs(5)).await?; let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
@@ -170,6 +195,37 @@ impl Robot {
} }
} }
fn split_relays(key: &str) -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|v| normalize_relay_url(v.trim()))
.filter(|v| !v.is_empty())
.collect()
}
fn normalize_relay_url(url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("ws://") || url.starts_with("wss://") {
url.to_string()
} else {
format!("wss://{url}")
}
}
async fn client_with_relays(secret: &str, relays: &[String]) -> Result<Client> {
let keys = Keys::parse(secret)?;
let client = Client::new(keys);
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn get_cached( async fn get_cached(
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>, cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
key: &str, key: &str,
@@ -198,3 +254,23 @@ async fn set_cached(
}, },
); );
} }
#[cfg(test)]
impl Robot {
pub fn test_stub() -> Self {
let keys = Keys::generate();
let client = Client::new(keys);
Self {
secret: String::new(),
name: String::new(),
description: String::new(),
picture: String::new(),
outbox_client: client.clone(),
indexer_client: client.clone(),
messaging_client: client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}
}
}
-21
View File
@@ -1,21 +0,0 @@
use std::sync::Arc;
use axum::extract::State;
use serde::Serialize;
use crate::api::{Api, AuthedPubkey};
use crate::web::{ApiResult, ok};
#[derive(Serialize)]
struct IdentityResponse {
pubkey: String,
is_admin: bool,
}
pub async fn get_identity(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
let is_admin = api.is_admin(&pubkey);
ok(IdentityResponse { pubkey, is_admin })
}
-86
View File
@@ -1,86 +0,0 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey};
use crate::web::{ApiResult, internal, not_found, ok};
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await
.map_err(internal)?;
ok(invoices)
}
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
ok(invoice)
}
pub async fn get_lightning_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
let lightning_invoice = api
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
.await
.map_err(internal)?;
ok(serde_json::json!(lightning_invoice))
}
-6
View File
@@ -1,6 +0,0 @@
pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod stripe;
pub mod tenants;
-17
View File
@@ -1,17 +0,0 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::Api;
use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
ok(api.query.list_plans())
}
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
match api.query.get_plan(&id) {
Some(plan) => ok(plan),
None => Err(not_found("plan not found")),
}
}
-315
View File
@@ -1,315 +0,0 @@
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
};
use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
};
use crate::web::{
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
parse_bool_default, unprocessable,
};
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
pub policy_public_join: i64,
pub policy_strip_signatures: i64,
pub groups_enabled: i64,
pub management_enabled: i64,
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let relays = api.query.list_relays().await.map_err(internal)?;
ok(relays)
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
ok(relay)
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let activity = api
.query
.list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
ok(serde_json::json!({ "members": members }))
}
pub async fn create_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Json(payload): Json<CreateRelayRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &payload.tenant)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let relay = Relay {
id: relay_id.clone(),
tenant: payload.tenant,
schema: relay_id.clone(),
subdomain: payload.subdomain,
plan: payload.plan,
info_name: payload.info_name,
info_icon: payload.info_icon,
info_description: payload.info_description,
policy_public_join: payload.policy_public_join,
policy_strip_signatures: payload.policy_strip_signatures,
groups_enabled: payload.groups_enabled,
management_enabled: payload.management_enabled,
blossom_enabled: payload.blossom_enabled,
livekit_enabled: payload.livekit_enabled,
push_enabled: payload.push_enabled,
..Default::default()
};
let relay = prepare_relay(&api, relay)?;
api.command
.create_relay(&relay)
.await
.map_err(map_relay_write_error)?;
created(relay)
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
if let Some(v) = payload.subdomain {
relay.subdomain = v;
}
if let Some(v) = requested_plan.clone() {
relay.plan = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
}
if let Some(v) = payload.info_icon {
relay.info_icon = v;
}
if let Some(v) = payload.info_description {
relay.info_description = v;
}
if let Some(v) = payload.policy_public_join {
relay.policy_public_join = v;
}
if let Some(v) = payload.policy_strip_signatures {
relay.policy_strip_signatures = v;
}
if let Some(v) = payload.groups_enabled {
relay.groups_enabled = v;
}
if let Some(v) = payload.management_enabled {
relay.management_enabled = v;
}
if let Some(v) = payload.blossom_enabled {
relay.blossom_enabled = v;
}
if let Some(v) = payload.livekit_enabled {
relay.livekit_enabled = v;
}
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
let relay = prepare_relay(&api, relay)?;
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&api, &relay)
.await
.map_err(internal)?
.len() as i64;
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Err(unprocessable("member-limit-exceeded", &message));
}
}
}
api.command
.update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
pub async fn deactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_INACTIVE {
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
}
api.command
.deactivate_relay(&relay)
.await
.map_err(internal)?;
ok(())
}
pub async fn reactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_ACTIVE {
return Err(bad_request("relay-is-active", "relay is already active"));
}
api.command.activate_relay(&relay).await.map_err(internal)?;
ok(())
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
api.infra.list_relay_members(&relay.id).await
}
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = api
.query
.get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, 0);
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 0);
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay)
}
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
unprocessable("subdomain-exists", "subdomain already exists")
} else {
internal(e)
}
}
-350
View File
@@ -1,350 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Path, Query as QueryParams, State},
http::HeaderMap,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
use crate::web::{ApiResult, bad_request, internal, ok};
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
pub async fn stripe_webhook(
State(api): State<Arc<Api>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult {
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let payload = std::str::from_utf8(&body)
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
handle_webhook(&api, payload, signature)
.await
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
ok(())
}
// --- Webhook event handlers ---
//
// These translate verified Stripe events into domain actions. The Stripe HTTP
// calls and Lightning/NWC payment orchestration they invoke live in
// [`crate::stripe`] and [`crate::billing`] respectively.
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
let event = api.stripe.get_webhook_event(payload, signature)?;
let obj = &event.data.object;
match event.event_type.as_str() {
"invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
}
"invoice.paid" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_paid(api, customer).await?;
}
"invoice.payment_failed" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_payment_failed(api, customer).await?;
}
"invoice.overdue" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_overdue(api, customer).await?;
}
"customer.subscription.updated" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let status = obj["status"].as_str().unwrap_or_default();
handle_subscription_updated(api, customer, status).await?;
}
"customer.subscription.deleted" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_subscription_deleted(api, customer).await?;
}
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_payment_method_attached(api, customer).await?;
}
_ => {}
}
Ok(())
}
async fn handle_invoice_created(
api: &Api,
stripe_customer_id: &str,
amount_due: i64,
currency: &str,
stripe_invoice_id: &str,
) -> Result<()> {
if amount_due == 0 {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoice = api
.billing
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
.await?;
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
Ok(()) => return Ok(()),
Err(e) => {
let error_msg = format!("{e}");
api.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
stripe_invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to card / manual payment
}
}
}
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
if api
.stripe
.has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
// 3. Manual payment: send a DM
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
}
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_some() {
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
api.command.activate_relay(&relay).await?;
}
}
}
Ok(())
}
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_none() {
api.command.set_tenant_past_due(&tenant.pubkey).await?;
api.robot
.send_dm(
&tenant.pubkey,
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
)
.await?;
}
Ok(())
}
async fn handle_subscription_updated(
api: &Api,
stripe_customer_id: &str,
status: &str,
) -> Result<()> {
if status != "canceled" && status != "unpaid" {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
Ok(())
}
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
Ok(())
}
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
api.robot
.send_dm(
&tenant.pubkey,
"Your paid relays have been deactivated due to non-payment.",
)
.await?;
Ok(())
}
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
return Some(normalized);
}
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
match nwc_error {
Some(error) if !error.is_empty() => {
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
}
}
-145
View File
@@ -1,145 +0,0 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
use crate::web::{ApiResult, internal, map_unique_error, ok};
#[derive(Serialize)]
pub struct TenantResponse {
pub pubkey: String,
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub created_at: i64,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
fn from(t: Tenant) -> Self {
TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
created_at: t.created_at,
stripe_customer_id: t.stripe_customer_id,
stripe_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
}
}
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let tenants = api.query.list_tenants().await.map_err(internal)?;
ok(tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>())
}
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
/// already exists (including a unique-constraint race) we return the existing
/// row.
pub async fn create_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t));
}
let display_name = api
.robot
.fetch_nostr_name(&pubkey)
.await
.unwrap_or(pubkey.chars().take(8).collect());
let stripe_customer_id = api
.stripe
.create_customer(&pubkey, &display_name)
.await
.map_err(internal)?;
let tenant = Tenant {
pubkey: pubkey.clone(),
created_at: Utc::now().timestamp(),
stripe_customer_id,
..Default::default()
};
match api.command.create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => ok(TenantResponse::from(t)),
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)),
}
}
Err(e) => Err(internal(e)),
}
}
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
pub async fn update_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Json(payload): Json<UpdateTenantRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
// Encrypt tenant's nwc_url at rest
if let Some(nwc_url) = payload.nwc_url {
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
}
}
api.command.update_tenant(&tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant))
}
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = api
.query
.list_relays_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(relays)
}
-412
View File
@@ -1,412 +0,0 @@
//! A thin async wrapper around the subset of the Stripe REST API this service uses.
//!
//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP
//! to Stripe and hands back `serde_json::Value` (or small typed results). The
//! domain logic lives in [`crate::billing`].
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
use crate::env::Env;
const STRIPE_API: &str = "https://api.stripe.com/v1";
// Webhooks
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(serde::Deserialize)]
pub struct StripeWebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeWebhookEventData,
}
#[derive(serde::Deserialize)]
pub struct StripeWebhookEventData {
pub object: serde_json::Value,
}
// API return types
#[derive(serde::Deserialize)]
pub struct StripeSubscription {
pub id: String,
pub status: String,
#[serde(deserialize_with = "deserialize_list")]
pub items: Vec<StripeSubscriptionItem>,
}
#[derive(serde::Deserialize)]
pub struct StripeSubscriptionItem {
pub id: String,
pub price: StripePrice,
#[serde(default = "default_quantity")]
pub quantity: i64,
}
#[derive(serde::Deserialize)]
pub struct StripePrice {
pub id: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct StripeInvoice {
pub id: String,
pub customer: String,
pub status: String,
pub amount_due: i64,
pub currency: String,
}
#[derive(serde::Deserialize)]
struct StripeList<T> {
data: Vec<T>,
}
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
}
fn default_quantity() -> i64 {
1
}
// Stripe struct and impl
#[derive(Clone)]
pub struct Stripe {
env: Env,
http: reqwest::Client,
}
impl Stripe {
pub fn new(env: &Env) -> Self {
Self {
env: env.clone(),
http: reqwest::Client::new(),
}
}
// --- Request helpers ---
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.get(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.delete(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
mac.update(b":");
}
mac.update(part.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
// --- Customers ---
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
let body = self
.post("/customers")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_customer", tenant_pubkey]),
)
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
.send_json()
.await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
Ok(customer_id.to_string())
}
// --- Subscriptions ---
pub async fn get_subscription(
&self,
subscription_id: &str,
) -> Result<Option<StripeSubscription>> {
let body = self
.get(&format!("/subscriptions/{subscription_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
/// Stripe requires at least one item to create a subscription, so the desired
/// items are sent inline here; [`crate::billing`] reconciles from there.
pub async fn create_subscription(
&self,
customer_id: &str,
items: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
let mut form: Vec<(String, String)> = vec![
("customer".to_string(), customer_id.to_string()),
(
"collection_method".to_string(),
"charge_automatically".to_string(),
),
];
let mut key_parts: Vec<String> =
vec!["create_subscription".to_string(), customer_id.to_string()];
for (index, (price_id, quantity)) in items.iter().enumerate() {
form.push((format!("items[{index}][price]"), price_id.clone()));
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
key_parts.push(format!("{price_id}={quantity}"));
}
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
Ok(self
.post("/subscriptions")
.header("Idempotency-Key", self.idempotency_key(&key_refs))
.form(&form)
.send_ok()
.await?
.json()
.await?)
}
pub async fn create_subscription_item(
&self,
subscription_id: &str,
price_id: &str,
quantity: i64,
) -> Result<()> {
let quantity = quantity.to_string();
self.post("/subscription_items")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
)
.form(&[
("subscription", subscription_id),
("price", price_id),
("quantity", quantity.as_str()),
])
.send_ok()
.await?;
Ok(())
}
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
self.post(&format!("/subscription_items/{item_id}"))
.form(&[("quantity", quantity.to_string())])
.send_ok()
.await?;
Ok(())
}
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
self.delete(&format!("/subscription_items/{item_id}"))
.send_ok()
.await?;
Ok(())
}
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
self.delete(&format!("/subscriptions/{subscription_id}"))
.send_ok()
.await?;
Ok(())
}
// --- Invoices ---
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
let list: StripeList<StripeInvoice> = self
.get("/invoices")
.query(&[("customer", customer_id)])
.send_ok()
.await?
.json()
.await?;
Ok(list.data)
}
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
let body = self
.get(&format!("/invoices/{invoice_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice", invoice_id]),
)
.send_ok()
.await?;
Ok(())
}
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
)
.form(&[("paid_out_of_band", "true")])
.send_ok()
.await?;
Ok(())
}
// --- Payment methods ---
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
let body = self
.get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")])
.send_json()
.await?;
Ok(body["data"].as_array().is_some_and(|a| !a.is_empty()))
}
// --- Portal ---
pub async fn create_portal_session(
&self,
customer_id: &str,
return_url: Option<&str>,
) -> Result<String> {
let mut params = vec![("customer", customer_id.to_string())];
if let Some(url) = return_url {
params.push(("return_url", url.to_string()));
}
let body = self
.post("/billing_portal/sessions")
.form(&params)
.send_json()
.await?;
body["url"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
// --- Webhooks ---
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
let mut timestamp = None;
let mut sig = None;
for part in signature.split(',') {
if let Some(t) = part.strip_prefix("t=") {
timestamp = Some(t);
} else if let Some(v) = part.strip_prefix("v1=") {
sig = Some(v);
}
}
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
mac.update(signed_payload.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if expected != signature {
return Err(anyhow!("webhook signature mismatch"));
}
let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance"));
}
Ok(serde_json::from_str(payload)?)
}
}
trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>;
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
}
impl StripeRequest for reqwest::RequestBuilder {
async fn send_ok(self) -> Result<reqwest::Response> {
error_for_status(self.send().await?).await
}
async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?)
}
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
let resp = self.send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(Some(error_for_status(resp).await?.json().await?))
}
}
/// Give callers an actionable message instead of a bare "400 Bad Request"
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if !status.is_client_error() && !status.is_server_error() {
return Ok(resp);
}
let url = resp.url().clone();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|json| {
let error = &json["error"];
let message = error["message"].as_str()?.to_string();
let mut detail = message;
if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) {
detail.push_str(&format!(" [{code}]"));
}
if let Some(param) = error["param"].as_str() {
detail.push_str(&format!(" (param: {param})"));
}
Some(detail)
})
.unwrap_or_else(|| {
if body.trim().is_empty() {
"<empty response body>".to_string()
} else {
body
}
});
Err(anyhow!(
"Stripe API request to {url} failed with status {status}: {detail}"
))
}
-60
View File
@@ -1,60 +0,0 @@
use anyhow::{Result, anyhow};
use nwc::prelude::{
LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest,
TransactionState,
};
#[derive(Clone)]
pub struct Wallet {
url: NostrWalletConnectURI,
}
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
let url = url
.parse::<NostrWalletConnectURI>()
.map_err(|_| anyhow!("invalid NWC URL"))?;
Ok(Self { url })
}
pub async fn make_invoice(
&self,
amount_msats: u64,
description: &str,
expiry_secs: u64,
) -> Result<String> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.make_invoice(MakeInvoiceRequest {
amount: amount_msats,
description: Some(description.to_string()),
description_hash: None,
expiry: Some(expiry_secs),
})
.await;
nwc.shutdown().await;
Ok(result
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
.invoice)
}
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
let nwc = NWC::new(self.url.clone());
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
nwc.shutdown().await;
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
}
pub async fn is_settled(&self, bolt11: &str) -> Result<bool> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.lookup_invoice(LookupInvoiceRequest {
payment_hash: None,
invoice: Some(bolt11.to_string()),
})
.await;
nwc.shutdown().await;
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some())
}
}
-125
View File
@@ -1,125 +0,0 @@
//! General-purpose HTTP helpers shared across route handlers.
//!
//! Success builders (`res`, `ok`, `created`) return [`ApiResult`] so they
//! can sit at the end of a handler without an `Ok(..)` wrap. Error builders
//! (`err`, `not_found`, `forbidden`, …) return [`ApiError`] so they compose
//! with `.map_err(...)` and with explicit `Err(...)` returns.
use std::fmt::Display;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
pub struct ApiError(pub Box<Response>);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
*self.0
}
}
impl From<Response> for ApiError {
fn from(r: Response) -> Self {
Self(Box::new(r))
}
}
pub type ApiResult = Result<Response, ApiError>;
#[derive(Serialize)]
pub struct DataResponse<T: Serialize> {
pub data: T,
pub code: &'static str,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
}
// --- success builders (return ApiResult) ------------------------------------
pub fn res<T: Serialize>(status: StatusCode, data: T) -> ApiResult {
Ok((status, Json(DataResponse { data, code: "ok" })).into_response())
}
pub fn ok<T: Serialize>(data: T) -> ApiResult {
res(StatusCode::OK, data)
}
pub fn created<T: Serialize>(data: T) -> ApiResult {
res(StatusCode::CREATED, data)
}
// --- error builders (return ApiError) ---------------------------------------
pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
(
status,
Json(ErrorResponse {
error: message.to_string(),
code: code.to_string(),
}),
)
.into_response()
.into()
}
pub fn unauthorized(reason: impl Display) -> ApiError {
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
}
pub fn forbidden(message: &str) -> ApiError {
err(StatusCode::FORBIDDEN, "forbidden", message)
}
pub fn not_found(message: &str) -> ApiError {
err(StatusCode::NOT_FOUND, "not-found", message)
}
pub fn bad_request(code: &str, message: &str) -> ApiError {
err(StatusCode::BAD_REQUEST, code, message)
}
pub fn unprocessable(code: &str, message: &str) -> ApiError {
err(StatusCode::UNPROCESSABLE_ENTITY, code, message)
}
pub fn internal(reason: impl Display) -> ApiError {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&reason.to_string(),
)
}
// --- misc utilities ---------------------------------------------------------
pub fn parse_bool_default(value: i64, default: i64) -> i64 {
if value == 0 || value == 1 {
value
} else {
default
}
}
/// Recognize sqlite UNIQUE constraint violations on known columns so the
/// caller can translate them into 422 responses instead of opaque 500s.
pub fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else {
return None;
};
if db_err.message().contains("pubkey") {
return Some("pubkey-exists");
}
if db_err.message().contains("subdomain") {
return Some("subdomain-exists");
}
None
}
+31
View File
@@ -0,0 +1,31 @@
use axum::{Json, Router, routing::get};
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
#[tokio::test]
async fn quote_endpoint_can_be_stubbed_deterministically() {
async fn spot() -> Json<serde_json::Value> {
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
}
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("get local addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve quote stub");
});
let client = reqwest::Client::new();
let base = format!("http://{addr}/v2/prices");
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
.await
.expect("fetch stubbed quote");
assert_eq!(btc_price, 50_000.0);
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price)
.expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend API base URL # Backend API base URL
VITE_API_URL=http://127.0.0.1:2892 VITE_API_URL=http://127.0.0.1:3000
# Platform display name shown in UI # Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel VITE_PLATFORM_NAME=Caravel
+3 -6
View File
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` | | `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` |
## Running ## Running
@@ -51,11 +51,8 @@ npm run preview
## Authentication ## Authentication
- Tenant requests use an intentional session-style variant of NIP-98: - Tenant requests use NIP-98 tokens derived from the logged-in user
- The client signs one kind `27235` event with `u = VITE_API_URL`. - Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend
- 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 ## Routes
-1
View File
@@ -1 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="220" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 32C0 14.3269 14.3269 0 32 0V0C49.6731 0 64 14.3269 64 32V32C64 49.6731 49.6731 64 32 64H8C3.58172 64 0 60.4183 0 56V32Z" fill="#6D29D9"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M21 22C19.3431 22 18 23.3431 18 25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25C14 21.134 17.134 18 21 18C24.866 18 28 21.134 28 25C28 26.1046 27.1046 27 26 27C24.8954 27 24 26.1046 24 25C24 23.3431 22.6569 22 21 22ZM43 22C41.3431 22 40 23.3431 40 25C40 26.1046 39.1046 27 38 27C36.8954 27 36 26.1046 36 25C36 21.134 39.134 18 43 18C46.866 18 50 21.134 50 25C50 26.1046 49.1046 27 48 27C46.8954 27 46 26.1046 46 25C46 23.3431 44.6569 22 43 22Z" fill="#FFFFFF"></path><path d="M32 47C38.6985 47 44.2982 42.2956 45.6755 36.0106C45.9829 34.608 44.5 33.5552 43.1016 33.8813C40.0379 34.5957 35.7213 35.1538 32 35.1538C28.2787 35.1538 23.9621 34.5957 20.8984 33.8813C19.5 33.5552 18.0171 34.608 18.3245 36.0106C19.7018 42.2956 25.3015 47 32 47Z" fill="#FFFFFF"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

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

Before

Width:  |  Height:  |  Size: 2.9 KiB

-151
View File
@@ -1,151 +0,0 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
+14 -25
View File
@@ -3,6 +3,7 @@ import QRCode from "qrcode"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api" import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error" type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -25,8 +26,8 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Error, setBolt11Error] = createSignal("") const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle") const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("") const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
async function loadBolt11() { async function loadBolt11() {
if (!props.invoice.id) return if (!props.invoice.id) return
@@ -62,6 +63,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const invoice = await getInvoice(props.invoice.id) const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") { if (invoice.status === "paid") {
setPayStatus("success") setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else { } else {
setPayStatus("error") setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.") setPayError("Payment not yet confirmed. Please try again after sending.")
@@ -79,6 +81,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setBolt11Error("") setBolt11Error("")
setBolt11("") setBolt11("")
setQrDataUrl("") setQrDataUrl("")
setShowSetup(false)
props.onClose() props.onClose()
} }
@@ -157,15 +160,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</button> </button>
</div> </div>
</Show> </Show>
<div class="text-center pt-1">
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-700"
>
Set up payment method instead
</button>
</div>
</Show> </Show>
</div> </div>
} }
@@ -178,13 +172,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div> </div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p> <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> <p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<button <Show when={showSetup()}>
type="button" <button
onClick={() => setShowPaymentSetup(true)} type="button"
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700" onClick={() => setShowPaymentSetup(true)}
> class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
Set up automatic payments >
</button> Set up automatic payments
</button>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@@ -232,14 +228,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Modal> </Modal>
<PaymentSetup <PaymentSetup
open={showPaymentSetup()} open={showPaymentSetup()}
onClose={() => { onClose={() => setShowPaymentSetup(false)}
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/> />
</> </>
) )
+3 -5
View File
@@ -9,7 +9,6 @@ type Tab = "nwc" | "card"
type PaymentSetupProps = { type PaymentSetupProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSaved?: () => void
} }
export default function PaymentSetup(props: PaymentSetupProps) { export default function PaymentSetup(props: PaymentSetupProps) {
@@ -28,7 +27,6 @@ export default function PaymentSetup(props: PaymentSetupProps) {
try { try {
await updateActiveTenant({ nwc_url: url }) await updateActiveTenant({ nwc_url: url })
setSaved(true) setSaved(true)
props.onSaved?.()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection") setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally { } finally {
@@ -40,7 +38,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
setRedirecting(true) setRedirecting(true)
setError("") setError("")
try { try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href) const { url } = await createPortalSession(account()!.pubkey)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") setError(e instanceof Error ? e.message : "Failed to open billing portal")
@@ -66,7 +64,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2> <h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay once invoices are issued for your relay.</p> <p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
</div> </div>
<button <button
type="button" type="button"
@@ -146,7 +144,7 @@ export default function PaymentSetup(props: PaymentSetupProps) {
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
</div> </div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.</p> <p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<button <button
type="button" type="button"
onClick={openPortal} onClick={openPortal}
+8 -69
View File
@@ -2,7 +2,6 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js" import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay, PlanId } from "@/lib/api" import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg" import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field" import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable" import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton" import ToggleButton from "@/components/ToggleButton"
@@ -52,8 +51,8 @@ type RelayDetailCardProps = {
currentMembers?: number currentMembers?: number
showTenant?: boolean showTenant?: boolean
editHref?: string editHref?: string
onDeactivate?: () => void | Promise<void> onDeactivate?: () => void
onReactivate?: () => void | Promise<void> onReactivate?: () => void
deactivating?: boolean deactivating?: boolean
reactivating?: boolean reactivating?: boolean
onTogglePublicJoin?: () => void onTogglePublicJoin?: () => void
@@ -77,7 +76,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const [menuOpen, setMenuOpen] = createSignal(false) const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan) const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined let menuContainerRef: HTMLDivElement | undefined
@@ -88,24 +86,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free" const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true 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) { async function changePlan(plan: PlanId) {
setPlan(plan) setPlan(plan)
@@ -117,29 +97,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
} }
} }
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
setPendingAction(null)
}
createEffect(() => { createEffect(() => {
if (!menuOpen()) return if (!menuOpen()) return
@@ -171,7 +128,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0"> <div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}> <Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" /> <img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show> </Show>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
@@ -191,7 +148,7 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</div> </div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}> <Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}> <div class="relative flex-shrink-0" ref={menuContainerRef}>
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50" class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
@@ -220,7 +177,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => { onClick={() => {
openActionDialog("deactivate") setMenuOpen(false)
props.onDeactivate?.()
}} }}
disabled={props.deactivating} disabled={props.deactivating}
> >
@@ -232,7 +190,8 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
type="button" type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50" class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => { onClick={() => {
openActionDialog("reactivate") setMenuOpen(false)
props.onReactivate?.()
}} }}
disabled={props.reactivating} disabled={props.reactivating}
> >
@@ -244,13 +203,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</div> </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" /> <hr class="border-gray-200" />
<DetailSection title="Policy"> <DetailSection title="Policy">
@@ -387,19 +339,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</DetailSection> </DetailSection>
</Show> </Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div> </div>
) )
} }
-7
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, For } from "solid-js" import { createEffect, createMemo, createSignal, For } from "solid-js"
import type { Relay } from "@/lib/hooks" import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify" import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state" import { plans } from "@/lib/state"
@@ -32,12 +31,6 @@ export default function RelayForm(props: RelayFormProps) {
return return
} }
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("") setToastMessage("")
setSubmitting(true) setSubmitting(true)
+1 -12
View File
@@ -1,5 +1,4 @@
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api" import type { Relay } from "@/lib/api"
type RelayListItemProps = { type RelayListItemProps = {
@@ -20,17 +19,7 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p> <p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)} )}
</div> </div>
<Show <p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>
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> </div>
</A> </A>
</li> </li>
+3 -13
View File
@@ -51,6 +51,7 @@ export type Relay = {
plan: PlanId plan: PlanId
status: string status: string
sync_error: string sync_error: string
stripe_subscription_item_id: string | null
synced: number synced: number
info_name: string info_name: string
info_icon: string info_icon: string
@@ -144,8 +145,6 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235, kind: 27235,
content: "", content: "",
created_at: Math.floor(now / 1000), 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]], tags: [["u", API_URL]],
}) })
@@ -204,10 +203,6 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity") return callApi<undefined, Identity>("GET", "/identity")
} }
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) { export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`) return callApi<undefined, Plan>("GET", `/plans/${id}`)
} }
@@ -240,10 +235,6 @@ export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`) 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) { export function listRelayActivity(id: string) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`) return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
} }
@@ -252,9 +243,8 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`) return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
} }
export function createPortalSession(pubkey: string, returnUrl?: string) { export function createPortalSession(pubkey: string) {
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : "" return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
} }
export function getInvoiceBolt11(invoiceId: string) { export function getInvoiceBolt11(invoiceId: string) {
+10 -9
View File
@@ -2,6 +2,7 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile" import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection" import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable" import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs" import { map, of } from "rxjs"
import { import {
createRelay, createRelay,
@@ -11,14 +12,12 @@ import {
getTenant, getTenant,
listRelayActivity, listRelayActivity,
listRelays, listRelays,
listTenantInvoices,
listTenantRelays, listTenantRelays,
listTenants, listTenants,
updateRelay, updateRelay,
updateTenant, updateTenant,
type Activity, type Activity,
type CreateRelayInput, type CreateRelayInput,
type Invoice,
type Relay, type Relay,
type Tenant, type Tenant,
type UpdateRelayInput, type UpdateRelayInput,
@@ -138,12 +137,14 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
return !tenant.nwc_url && !tenant.stripe_subscription_id return !tenant.nwc_url && !tenant.stripe_subscription_id
} }
export async function getLatestOpenInvoice(): Promise<Invoice | null> { export async function getRelayMembers(url: string) {
const invoices = await listTenantInvoices(account()!.pubkey) const management = new RelayManagement(new NostrRelay(url), account()!.signer)
const open = invoices
.filter(inv => inv.status === "open" && inv.amount_due > 0) try {
.sort((a, b) => b.period_start - a.period_start) return await management.listAllowedPubkeys()
return open[0] ?? null } catch {
return []
}
} }
export type { Activity, Invoice, Relay, Tenant } export type { Activity, Relay, Tenant }
-22
View File
@@ -1,22 +0,0 @@
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
export function validateSubdomainLabel(subdomain: string): string | null {
if (subdomain.length === 0) {
return "subdomain is required"
}
if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) {
return "subdomain must be 63 characters or fewer"
}
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
return "subdomain cannot start or end with a hyphen"
}
if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) {
return "subdomain is reserved"
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return "subdomain may only contain lowercase letters, numbers, and hyphens"
}
return null
}
+6 -11
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks" import { updateRelayById, deactivateRelayById, reactivateRelayById, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast" import { setToastMessage } from "@/components/Toast"
import type { Invoice, PlanId } from "@/lib/api" import type { PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean { function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false if (value === 0) return false
@@ -30,8 +30,7 @@ export default function useRelayToggles(
{ refetch, mutate }: RelayActions, { refetch, mutate }: RelayActions,
) { ) {
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [needsPaymentSetup, setNeedsPaymentSetup] = createSignal(false)
const [pendingPaymentSetup, setPendingPaymentSetup] = createSignal(false)
async function updateRelay(next: Relay, previous: Relay) { async function updateRelay(next: Relay, previous: Relay) {
mutate(next) mutate(next)
@@ -102,12 +101,8 @@ export default function useRelayToggles(
} }
if (plan !== "free") { if (plan !== "free") {
const needsSetup = await tenantNeedsPaymentSetup() const needs = await tenantNeedsPaymentSetup()
if (needsSetup) { if (needs) setNeedsPaymentSetup(true)
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
setPendingPaymentSetup(true)
}
} }
} }
@@ -121,5 +116,5 @@ export default function useRelayToggles(
onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"), onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"),
} }
return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), pendingPaymentSetup, clearPendingPaymentSetup: () => setPendingPaymentSetup(false), toggles } return { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup: () => setNeedsPaymentSetup(false), toggles }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Account() {
async function openPortal() { async function openPortal() {
setPortalLoading(true) setPortalLoading(true)
try { try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href) const { url } = await createPortalSession(account()!.pubkey)
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal") setError(e instanceof Error ? e.message : "Failed to open billing portal")
+7 -46
View File
@@ -8,9 +8,6 @@ import Modal from "@/components/Modal"
import Login from "@/views/Login" import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks" import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state" 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() { export default function Home() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -216,7 +213,7 @@ export default function Home() {
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Flotilla */} {/* Flotilla */}
<a <a
href="https://flotilla.social" href="https://flotilla.social"
@@ -226,7 +223,9 @@ export default function Home() {
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" /> <div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200">
F
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3> <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> <p class="text-xs text-gray-400">flotilla.social</p>
@@ -264,7 +263,9 @@ export default function Home() {
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" /> <div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200">
C
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3> <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> <p class="text-xs text-gray-400">chachi.chat</p>
@@ -292,45 +293,6 @@ export default function Home() {
</span> </span>
</div> </div>
</a> </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> </div>
</section> </section>
@@ -376,7 +338,6 @@ export default function Home() {
<div class="flex gap-4"> <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://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://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>
</div> </div>
</footer> </footer>
+1 -12
View File
@@ -1,12 +1,11 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createResource, Show } from "solid-js" import { Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks" import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
@@ -14,15 +13,6 @@ export default function AdminRelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) 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 loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
@@ -36,7 +26,6 @@ export default function AdminRelayDetail() {
<div class="space-y-6 mb-6"> <div class="space-y-6 mb-6">
<RelayDetailCard <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members()?.length}
showTenant showTenant
editHref={`/admin/relays/${params.id}/edit`} editHref={`/admin/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
+10 -115
View File
@@ -1,66 +1,27 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard" import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed" import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api" import { getRelayMembers, useRelay, useRelayActivity } from "@/lib/hooks"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles" import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
export default function RelayDetail() { export default function RelayDetail() {
const params = useParams() const params = useParams()
const relayId = () => params.id ?? "" const relayId = () => params.id ?? ""
const [relay, { refetch, mutate }] = useRelay(relayId) const [relay, { refetch, mutate }] = useRelay(relayId)
const [members] = createResource(relayId, async (id) => { const relayUrl = createMemo(() => {
if (!id) return [] const subdomain = relay()?.subdomain
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
try {
return (await listRelayMembers(id)).members
} catch {
return []
}
}) })
const [members] = createResource(relayUrl, getRelayMembers)
const loading = useMinLoading(() => relay.loading && !relay()) const loading = useMinLoading(() => relay.loading && !relay())
const [activity] = useRelayActivity(relayId) const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, needsPaymentSetup, clearNeedsPaymentSetup, 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_url
})
return ( return (
<PageContainer> <PageContainer>
@@ -69,47 +30,9 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}> <Show when={!loading() && relay()}>
{(r) => ( {(r) => (
<div class="space-y-6 mb-6"> <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 <RelayDetailCard
relay={r()} relay={r()}
currentMembers={members()?.length} currentMembers={members.length}
editHref={`/relays/${params.id}/edit`} editHref={`/relays/${params.id}/edit`}
onDeactivate={handleDeactivate} onDeactivate={handleDeactivate}
onReactivate={handleReactivate} onReactivate={handleReactivate}
@@ -122,37 +45,9 @@ export default function RelayDetail() {
</div> </div>
)} )}
</Show> </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 <PaymentSetup
open={paymentSetupOpen()} open={needsPaymentSetup()}
onClose={() => { onClose={clearNeedsPaymentSetup}
setPaymentSetupOpen(false)
void refetchTenant()
}}
/> />
</PageContainer> </PageContainer>
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Show } from "solid-js" import { Show } from "solid-js"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { slugify } from "@/lib/slugify"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState" import ResourceState from "@/components/ResourceState"
@@ -17,7 +18,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
await updateRelayById(relayId(), { await updateRelayById(relayId(), {
subdomain: values.subdomain, subdomain: slugify(values.subdomain),
info_name: values.info_name.trim(), info_name: values.info_name.trim(),
info_icon: values.info_icon.trim(), info_icon: values.info_icon.trim(),
info_description: values.info_description.trim(), info_description: values.info_description.trim(),
+10 -32
View File
@@ -1,17 +1,14 @@
import { createSignal, Show } from "solid-js" import { createSignal } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink" import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer" import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup" import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks" import { createRelayForActiveTenant, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
export default function RelayNew() { export default function RelayNew() {
const navigate = useNavigate() const navigate = useNavigate()
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>() const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = "" let createdRelayId = ""
async function handleSubmit(values: RelayFormValues) { async function handleSubmit(values: RelayFormValues) {
@@ -19,14 +16,9 @@ export default function RelayNew() {
createdRelayId = relay.id createdRelayId = relay.id
if (values.plan !== "free") { if (values.plan !== "free") {
const needsSetup = await tenantNeedsPaymentSetup() const needs = await tenantNeedsPaymentSetup()
if (needsSetup) { if (needs) {
const invoice = await getLatestOpenInvoice() setShowPaymentSetup(true)
if (invoice) {
setPendingInvoice(invoice)
return
}
setPaymentSetupOpen(true)
return return
} }
} }
@@ -34,13 +26,8 @@ export default function RelayNew() {
navigate(`/relays/${relay.id}`) navigate(`/relays/${relay.id}`)
} }
function handleInvoiceClose() { function handleDialogClose() {
setPendingInvoice(undefined) setShowPaymentSetup(false)
setPaymentSetupOpen(true)
}
function handleSetupClose() {
setPaymentSetupOpen(false)
navigate(`/relays/${createdRelayId}`) navigate(`/relays/${createdRelayId}`)
} }
@@ -54,18 +41,9 @@ export default function RelayNew() {
submitLabel="Create Relay" submitLabel="Create Relay"
submittingLabel="Creating..." submittingLabel="Creating..."
/> />
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup <PaymentSetup
open={paymentSetupOpen()} open={showPaymentSetup()}
onClose={handleSetupClose} onClose={handleDialogClose}
/> />
</PageContainer> </PageContainer>
) )
-7
View File
@@ -5,7 +5,6 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner" import QrScanner from "qr-scanner"
import QRCode from "qrcode" import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
@@ -70,12 +69,6 @@ export default function Login(props: LoginPageProps = {}) {
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account) accountManager.addAccount(account)
accountManager.setActive(account) accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.() await props.onAuthenticated?.()
} }
+7 -14
View File
@@ -1,20 +1,13 @@
import { defineConfig, loadEnv } from 'vite' import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid' import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig(({mode}) => { export default defineConfig({
const env = loadEnv(mode, process.cwd(), '') plugins: [tailwindcss(), solid()],
resolve: {
return { alias: {
plugins: [tailwindcss(), solid()], '@': fileURLToPath(new URL('./src', import.meta.url)),
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
server: { },
port: Number(env.VITE_PORT) || 5173,
},
}
}) })
+1 -4
View File
@@ -5,9 +5,6 @@ dev:
cd frontend && bun dev & cd frontend && bun dev &
wait wait
dev-backend:
cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run'
dev-frontend: dev-frontend:
cd frontend && bun run dev cd frontend && bun run dev
@@ -30,7 +27,7 @@ build-backend:
cd backend && cargo build cd backend && cargo build
build-frontend: build-frontend:
cd frontend && bun i && bun run build cd frontend && bun run build
fmt: fmt-backend fmt: fmt-backend
+2
View File
@@ -0,0 +1,2 @@
- [ ] 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