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..03a9d0e 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -96,6 +96,31 @@ impl Infra { Ok(auth) } + pub async fn list_relay_members(&self, relay_id: &str) -> Result> { + if self.api_url.trim().is_empty() { + anyhow::bail!("missing ZOOID_API_URL"); + } + if self.api_secret.trim().is_empty() { + anyhow::bail!("missing ZOOID_API_SECRET"); + } + + 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('/'); @@ -156,6 +181,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..69ff927 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> Result<()> { 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 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/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index d40cd89..e63855d 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -8,7 +8,8 @@ import RelayDetailCard from "@/components/RelayDetailCard" import ResourceState from "@/components/ResourceState" import useMinLoading from "@/components/useMinLoading" import ActivityFeed from "@/components/ActivityFeed" -import { getLatestOpenInvoice, getRelayMembers, useRelay, useRelayActivity, useTenant } from "@/lib/hooks" +import { listRelayMembers } from "@/lib/api" +import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks" import useRelayToggles from "@/lib/useRelayToggles" import { plans } from "@/lib/state" @@ -16,11 +17,15 @@ export default function RelayDetail() { const params = useParams() const relayId = () => params.id ?? "" const [relay, { refetch, mutate }] = useRelay(relayId) - const relayUrl = createMemo(() => { - const subdomain = relay()?.subdomain - return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined + const [members] = createResource(relayId, async (id) => { + if (!id) return [] + + try { + return (await listRelayMembers(id)).members + } catch { + return [] + } }) - const [members] = createResource(relayUrl, getRelayMembers) const loading = useMinLoading(() => relay.loading && !relay()) const [activity] = useRelayActivity(relayId) const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) @@ -97,7 +102,7 @@ export default function RelayDetail() {