diff --git a/backend/README.md b/backend/README.md index 783a90d..040f425 100644 --- a/backend/README.md +++ b/backend/README.md @@ -74,13 +74,20 @@ Public exceptions: - `GET /tenants` — list tenants (admin) - `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise) - `GET /tenants/:pubkey` — get tenant (admin or same tenant) -- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) -- `GET /relays` — list relays (`?tenant=` allowed for admin only) +- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant) +- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant) +- `GET /relays` — list relays (admin) - `POST /relays` — create relay (admin or relay tenant) - `GET /relays/:id` — get relay (admin or relay tenant) +- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant) - `PUT /relays/:id` — update relay (admin or relay tenant) +- `GET /relays/:id/activity` — list relay activity (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) -- `GET /invoices` — list invoices (`?tenant=` allowed for admin only) +- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant) +- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant) +- `GET /invoices/:id` — get invoice (admin or same tenant) +- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant) +- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant) ## API Auth Model diff --git a/backend/spec/api.md b/backend/spec/api.md index f0a7d8a..663ea97 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -9,6 +9,7 @@ Members: - `query: Query` - `command: Command` - `billing: Billing` +- `infra: Infra` Notes: @@ -103,6 +104,14 @@ Notes: - Authorizes admin or relay owner - Return `data` is a single relay struct from `query.get_relay` +## `async fn list_relay_members(...) -> Response` + +- Serves `GET /relays/:id/members` +- Authorizes admin or relay owner +- For unsynced relays, returns an empty member list without calling zooid +- For synced relays, proxies the member list from zooid via `infra` +- Return `data` is `{ members }` + ## `async fn create_relay(...) -> Response` - Serves `POST /relays` @@ -117,6 +126,7 @@ Notes: - Serves `PUT /relays/:id` - Authorizes admin or relay owner - Validates/prepares the relay data to be saved using `prepare_relay` +- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded` - Updates the given relay using `command.update_relay` - If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists` - Return `data` is a single relay struct. diff --git a/backend/src/api.rs b/backend/src/api.rs index b5a6cfb..4fe2f9b 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use anyhow::anyhow; +use anyhow::{Result, anyhow}; use axum::{ Json, Router, extract::{Path, State}, @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::billing::{Billing, InvoiceLookupError}; use crate::command::Command; +use crate::infra::Infra; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, }; @@ -27,6 +28,7 @@ pub struct Api { query: Query, command: Command, billing: Billing, + infra: Infra, } async fn stripe_webhook( @@ -117,7 +119,7 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError { } impl Api { - pub fn new(query: Query, command: Command, billing: Billing) -> Self { + pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self { let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let admins = std::env::var("ADMINS") .unwrap_or_default() @@ -131,6 +133,7 @@ impl Api { query, command, billing, + infra, } } @@ -148,6 +151,7 @@ impl Api { .route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/relays", get(list_relays).post(create_relay)) .route("/relays/:id", get(get_relay).put(update_relay)) + .route("/relays/:id/members", get(list_relay_members)) .route("/relays/:id/activity", get(list_relay_activity)) .route("/relays/:id/deactivate", post(deactivate_relay)) .route("/relays/:id/reactivate", post(reactivate_relay)) @@ -251,6 +255,14 @@ impl Api { } } + async fn fetch_relay_members(&self, relay: &Relay) -> Result> { + if relay.synced == 0 { + return Ok(Vec::new()); + } + + self.infra.list_relay_members(&relay.id).await + } + fn prepare_relay(&self, mut relay: Relay) -> std::result::Result { validate_subdomain_label(&relay.subdomain)?; @@ -669,6 +681,40 @@ async fn list_relay_activity( } } +async fn list_relay_members( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> std::result::Result { + let auth = state.api.extract_auth_pubkey(&headers)?; + + let relay = match state.api.query.get_relay(&id).await { + Ok(Some(r)) => r, + Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )); + } + }; + + state.api.require_admin_or_tenant(&auth, &relay.tenant)?; + + match state.api.fetch_relay_members(&relay).await { + Ok(members) => Ok(ok( + StatusCode::OK, + serde_json::json!({ "members": members }), + )), + Err(e) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )), + } +} + async fn create_relay( State(state): State, headers: HeaderMap, @@ -748,10 +794,13 @@ async fn update_relay( state.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) = payload.plan { + if let Some(v) = requested_plan.clone() { relay.plan = v; } if let Some(v) = payload.info_name { @@ -792,6 +841,38 @@ async fn update_relay( } }; + let plan_changed = requested_plan + .as_deref() + .is_some_and(|requested| requested != current_plan); + + if plan_changed { + let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist"); + if let Some(limit) = selected_plan.members { + let current_members = match state.api.fetch_relay_members(&relay).await { + Ok(members) => members.len() as i64, + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )); + } + }; + + 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 Ok(err( + StatusCode::UNPROCESSABLE_ENTITY, + "member-limit-exceeded", + &message, + )); + } + } + } + match state.api.command.update_relay(&relay).await { Ok(()) => Ok(ok(StatusCode::OK, relay)), Err(e) => { diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 6f09271..cd922fe 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -2,7 +2,7 @@ use anyhow::Result; use nostr_sdk::prelude::*; use crate::command::Command; -use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE}; +use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay}; use crate::query::Query; #[derive(Clone)] @@ -18,14 +18,22 @@ pub struct Infra { } impl Infra { - pub fn new(query: Query, command: Command) -> Self { + pub fn new(query: Query, command: Command) -> Result { 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 { + + if api_url.trim().is_empty() { + anyhow::bail!("missing ZOOID_API_URL"); + } + if api_secret.trim().is_empty() { + anyhow::bail!("missing ZOOID_API_SECRET"); + } + + Ok(Self { api_url, relay_domain, livekit_url, @@ -34,7 +42,7 @@ impl Infra { api_secret, query, command, - } + }) } pub async fn start(self) { @@ -96,6 +104,28 @@ impl Infra { Ok(auth) } + pub async fn list_relay_members(&self, relay_id: &str) -> Result> { + let client = reqwest::Client::new(); + let base = self.api_url.trim_end_matches('/'); + let url = format!("{base}/relay/{relay_id}/members"); + let auth = self.nip98_auth(&url, HttpMethod::GET).await?; + + let response = client + .get(&url) + .header("Authorization", auth) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("zooid members returned {status}: {body}"); + } + + let body = response.text().await?; + parse_relay_members_response(&body) + } + async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> { let client = reqwest::Client::new(); let base = self.api_url.trim_end_matches('/'); @@ -125,7 +155,9 @@ impl Infra { ); let url = format!("{}/relay/{}", base, relay.id); - let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?; + let auth = self + .nip98_auth(&url, zooid_sync_http_method(is_new)) + .await?; let request = if is_new { client.post(&url) @@ -156,6 +188,34 @@ fn zooid_sync_http_method(is_new: bool) -> HttpMethod { } } +fn parse_relay_members_response(body: &str) -> Result> { + let value: serde_json::Value = serde_json::from_str(body)?; + + if let Some(members) = members_from_value(&value) { + return Ok(members); + } + if let Some(members) = value.get("members").and_then(members_from_value) { + return Ok(members); + } + if let Some(members) = value + .get("data") + .and_then(|data| data.get("members")) + .and_then(members_from_value) + { + return Ok(members); + } + + anyhow::bail!("zooid members response missing members array") +} + +fn members_from_value(value: &serde_json::Value) -> Option> { + let values = value.as_array()?; + values + .iter() + .map(|value| value.as_str().map(ToString::to_string)) + .collect() +} + fn relay_sync_body( relay: &Relay, host: String, diff --git a/backend/src/main.rs b/backend/src/main.rs index 7fecdc7..1751165 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -33,8 +33,8 @@ async fn main() -> Result<()> { let query = Query::new(pool.clone()); let command = Command::new(pool); let billing = Billing::new(query.clone(), command.clone(), robot.clone()); - let infra = Infra::new(query.clone(), command.clone()); - let api = Api::new(query, command, billing.clone()); + let infra = Infra::new(query.clone(), command.clone())?; + let api = Api::new(query, command, billing.clone(), infra.clone()); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port: u16 = std::env::var("PORT") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a5068a8..f9280d7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -241,6 +241,10 @@ export function getRelay(id: string) { return callApi("GET", `/relays/${id}`) } +export function listRelayMembers(id: string) { + return callApi("GET", `/relays/${id}/members`) +} + export function listRelayActivity(id: string) { return callApi("GET", `/relays/${id}/activity`) } diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index ffc92c2..87e8c81 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -2,7 +2,6 @@ import { createEffect, createResource, createSignal, onCleanup } from "solid-js" import { getProfilePicture } from "applesauce-core/helpers/profile" import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection" import { includeMailboxes } from "applesauce-core/observable" -import { Relay as NostrRelay, RelayManagement } from "applesauce-relay" import { map, of } from "rxjs" import { createRelay, @@ -147,14 +146,4 @@ export async function getLatestOpenInvoice(): Promise { return open[0] ?? null } -export async function getRelayMembers(url: string) { - const management = new RelayManagement(new NostrRelay(url), account()!.signer) - - try { - return await management.listAllowedPubkeys() - } catch { - return [] - } -} - export type { Activity, Invoice, Relay, Tenant } diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index f383414..adda082 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -1,11 +1,12 @@ import { useParams } from "@solidjs/router" -import { Show } from "solid-js" +import { createResource, Show } from "solid-js" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" import RelayDetailCard from "@/components/RelayDetailCard" import ResourceState from "@/components/ResourceState" import useMinLoading from "@/components/useMinLoading" import ActivityFeed from "@/components/ActivityFeed" +import { listRelayMembers } from "@/lib/api" import { useRelay, useRelayActivity } from "@/lib/hooks" import useRelayToggles from "@/lib/useRelayToggles" @@ -13,6 +14,15 @@ export default function AdminRelayDetail() { const params = useParams() const relayId = () => params.id ?? "" const [relay, { refetch, mutate }] = useRelay(relayId) + const [members] = createResource(relayId, async (id) => { + if (!id) return [] + + try { + return (await listRelayMembers(id)).members + } catch { + return [] + } + }) const loading = useMinLoading(() => relay.loading && !relay()) const [activity] = useRelayActivity(relayId) const { busy, handleDeactivate, handleReactivate, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) @@ -26,6 +36,7 @@ export default function AdminRelayDetail() {
params.id ?? "" const [relay, { refetch, mutate }] = useRelay(relayId) - const relayUrl = createMemo(() => { - const subdomain = relay()?.subdomain - return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined + const [members] = createResource(relayId, async (id) => { + if (!id) return [] + + try { + return (await listRelayMembers(id)).members + } catch { + return [] + } }) - const [members] = createResource(relayUrl, getRelayMembers) const loading = useMinLoading(() => relay.loading && !relay()) const [activity] = useRelayActivity(relayId) const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) @@ -97,7 +102,7 @@ export default function RelayDetail() {