diff --git a/backend/.env.template b/backend/.env.template index f63efd2..0eb559d 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,26 +1,29 @@ # Server HOST=127.0.0.1 PORT=3000 +ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive + +# Auth +ADMINS= # Comma-separated hex pubkeys with admin access # Database DATABASE_URL=sqlite://data/caravel.db -# Nostr -PLATFORM_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with super admin access -PLATFORM_SECRET= # Nostr private key (hex) used to send NIP-17 DMs and sign events +# Robot identity (published as kind 0) +ROBOT_SECRET= # Nostr private key (hex) +ROBOT_NAME= +ROBOT_DESCRIPTION= +ROBOT_PICTURE= +ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol +ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social +ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub # Zooid -ZOOID_API_URL=http://127.0.0.1:8032 +ZOOID_API_URL=http://127.0.0.1:3334 RELAY_DOMAIN=spaces.coracle.social +LIVEKIT_URL= +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= # Billing NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices - -# Indexer -NOSTR_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.band/ - -# Platform identity (published as kind 0) -PLATFORM_NAME= -PLATFORM_DESCRIPTION= -PLATFORM_PICTURE= -PLATFORM_MESSAGING_RELAYS= # Comma-separated relay URLs published in kind 10050 diff --git a/backend/README.md b/backend/README.md index 9b21117..6e94422 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,30 +1,29 @@ # Backend -Rust backend for the Nostr relay hosting platform. This service manages tenants, relays, invoices, and billing, and provisions relays by calling the zooid HTTP API. It authenticates requests using NIP-98. +Rust backend for Caravel. It manages tenants, relays, invoices, and background workers for relay provisioning and billing. ## Tech Stack - Rust (Edition 2024) -- Axum (HTTP server) -- SQLx + SQLite (persistence) -- Nostr SDK (NIP-98 verification) +- Axum (HTTP API) +- SQLx + SQLite +- Tokio (async runtime + workers) +- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect) ## Layout ``` backend/ - migrations/ # SQL migrations + migrations/ + 0001_init.sql src/ - auth.rs # NIP-98 verification helper - billing.rs # Billing loop + invoice generation - config.rs # Env-based configuration - db.rs # SQLite pool + migrations - main.rs # Axum server entrypoint - models.rs # Data models - notifications.rs # NIP-17 DM sender + relay discovery - platform.rs # Startup kind 0/10050 publishing - provisioning.rs # Zooid provisioning worker - repo.rs # Data access layer + api.rs # Axum routes + NIP-98 auth checks + billing.rs # Invoice generation + collection worker + infra.rs # Zooid sync worker + main.rs # App bootstrap + models.rs # DB models + repo.rs # Data access layer + robot.rs # Nostr robot identity + DM sending ``` ## Configuration @@ -33,95 +32,42 @@ Environment variables: | Variable | Description | Default | |---|---|---| -| `DATABASE_URL` | SQLite database URL | `sqlite://data/caravel.db` | -| `HOST` | Bind host | `127.0.0.1` | -| `PORT` | Bind port | `3000` | -| `ZOOID_API_URL` | Zooid API base URL | `http://127.0.0.1:8032` | -| `PLATFORM_SECRET` | Platform Nostr secret key for NIP-98 auth | _required_ | -| `RELAY_DOMAIN` | Relay base domain for subdomains | `spaces.coracle.social` | -| `NWC_URL` | Platform NWC URL for invoice generation | _required for billing_ | -| `NOSTR_INDEXER_RELAYS` | Comma-separated relays to fetch kind `10050` DM relays | _required for notifications_ | -| `PLATFORM_NAME` | Platform display name for kind `0` metadata | _optional_ | -| `PLATFORM_DESCRIPTION` | Platform description for kind `0` metadata | _optional_ | -| `PLATFORM_PICTURE` | Platform picture URL for kind `0` metadata | _optional_ | -| `PLATFORM_MESSAGING_RELAYS` | Comma-separated relays published in kind `10050` | _optional_ | +| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite:///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_ | +| `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_ | -The database directory is created automatically if it doesn’t exist. +Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended. -## Database Schema +## Schema and Architecture -Created via `migrations/0001_init.sql`: - -- `tenants` -- `relays` -- `invoices` -- `invoice_items` - -## Running - -```bash -cd backend -cargo run -``` - -Health check: - -``` -GET /healthz -``` - -## NIP-98 Authentication - -NIP-98 verification is implemented in `auth.rs` using the Rust Nostr SDK. It verifies: - -- Authorization header format -- Event signature and kind -- URL + HTTP method tags -- Timestamp validity - -This is ready to be used by API routes. - -## Billing Jobs - -The backend runs an in-process billing loop that: - -- Generates monthly invoices (using `NWC_URL`) -- Uses the tenant’s `nwc_url` for recurring pull payments (if set) -- Sends NIP-17 DMs with invoices when recurring is off -- Sends NIP-17 DMs on successful payment when recurring is on - -NIP-17 relay discovery: - -- Uses `NOSTR_INDEXER_RELAYS` to fetch kind `10050` for each tenant -- Cached for a short period -- If no relays are found, no DM is sent - -On startup, the backend publishes: - -- Kind `0` metadata (name/description) -- Kind `10050` relay list for DMs - -These are published to the relays listed in `NOSTR_INDEXER_RELAYS`. +See [spec](spec) for more details ## API Routes -Tenant routes (all require NIP-98 auth; pubkey is inferred from the token): +All routes are NIP-98 protected. -- `GET /tenant` — fetch (or create) tenant -- `GET /tenant/relays` — list tenant relays -- `POST /tenant/relays` — create relay -- `GET /tenant/relays/:id` — get relay -- `PUT /tenant/relays/:id` — update relay -- `DELETE /tenant/relays/:id` — deactivate relay -- `GET /tenant/invoices` — list invoices -- `PUT /tenant/billing` — update tenant billing (NWC URL) - -Admin routes (all require NIP-98 auth; pubkey must be in `PLATFORM_ADMIN_PUBKEYS`): - -- `GET /admin/tenants` — list tenants -- `GET /admin/tenants/:pubkey` — tenant detail (includes relays) -- `PUT /admin/tenants/:pubkey` — update tenant status -- `GET /admin/relays` — list relays -- `GET /admin/relays/:id` — get relay -- `PUT /admin/relays/:id` — update relay -- `DELETE /admin/relays/:id` — deactivate relay +- `GET /tenants` — list tenants (admin) +- `POST /tenants` — create current auth pubkey as tenant +- `GET /tenants/:pubkey` — get tenant (admin or same tenant) +- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) +- `GET /relays` — list relays (`?tenant=` allowed for admin only) +- `POST /relays` — create relay (admin or relay tenant) +- `GET /relays/:id` — get relay (admin or relay tenant) +- `PUT /relays/:id` — update relay (admin or relay tenant) +- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) +- `GET /invoices` — list invoices (`?tenant=` allowed for admin only) diff --git a/backend/spec/robot.md b/backend/spec/robot.md index dee0895..922cf26 100644 --- a/backend/spec/robot.md +++ b/backend/spec/robot.md @@ -15,7 +15,7 @@ Members: ## `pub fn new() -> Self` -- Reads environment and populates members +- Reads environment and populates members. Relay urls should be split and normalized. - Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections using `client` ## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>` diff --git a/backend/src/robot.rs b/backend/src/robot.rs index fbb3977..d0f7ea0 100644 --- a/backend/src/robot.rs +++ b/backend/src/robot.rs @@ -35,9 +35,19 @@ impl Robot { 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_env("ROBOT_OUTBOX_RELAYS"); - let indexer_relays = split_env("ROBOT_INDEXER_RELAYS"); - let messaging_relays = split_env("ROBOT_MESSAGING_RELAYS"); + 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 keys = Keys::parse(&secret)?; let client = Client::new(keys); @@ -190,15 +200,27 @@ impl Robot { } } -fn split_env(key: &str) -> Vec { +fn split_relays(key: &str) -> Vec { std::env::var(key) .unwrap_or_default() .split(',') - .map(|v| v.trim().to_string()) + .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 indexer_client(secret: &str, indexer_relays: &[String]) -> Result { let keys = Keys::parse(secret)?; let client = Client::new(keys);