diff --git a/backend/migrations/0005_add_relay_config.sql b/backend/migrations/0005_add_relay_config.sql new file mode 100644 index 0000000..a41ad75 --- /dev/null +++ b/backend/migrations/0005_add_relay_config.sql @@ -0,0 +1 @@ +ALTER TABLE relays ADD COLUMN config TEXT; diff --git a/backend/src/api.rs b/backend/src/api.rs index dd20c70..d77fe81 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::verify_nip98; -use crate::models::{NewRelay, NewTenant, Relay, UpdateRelay}; +use crate::models::{NewTenant, Relay, RelayConfig}; use crate::provisioning::Provisioner; use crate::repo::Repo; @@ -211,6 +211,7 @@ struct CreateRelayRequest { icon: String, description: String, plan: String, + config: Option, } async fn create_tenant_relay( @@ -241,7 +242,7 @@ async fn create_tenant_relay( .into_response(); } - let relay = NewRelay { + let relay = Relay { id: Uuid::new_v4().to_string(), tenant: pubkey.clone(), name: payload.name, @@ -251,9 +252,10 @@ async fn create_tenant_relay( description: payload.description, plan: payload.plan, status: "pending".to_string(), + config: payload.config, }; - if let Err(err) = state.repo.create_relay(&relay).await { + if let Err(err) = state.repo.upsert_relay(&relay).await { if is_unique_subdomain_violation(&err) { return ( StatusCode::CONFLICT, @@ -273,7 +275,17 @@ async fn create_tenant_relay( .into_response(); } - spawn_provisioning_worker(state.repo.clone(), state.provisioner.clone(), relay.clone()); + if let Err(err) = state.provisioner.sync_relay(&relay, true).await { + tracing::error!(relay_id = relay.id, error = %err, "zooid create failed"); + let _ = state.repo.update_relay_status(&relay.id, "provisioning_failed").await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { error: format!("failed to provision relay: {err}") }), + ) + .into_response(); + } + + let _ = state.repo.update_relay_status(&relay.id, "active").await; (StatusCode::CREATED, Json(relay)).into_response() } @@ -311,6 +323,7 @@ struct UpdateRelayRequest { icon: String, description: String, plan: String, + config: Option, } async fn update_tenant_relay( @@ -344,8 +357,9 @@ async fn update_tenant_relay( return forbidden(); } - let updated = UpdateRelay { + let relay = Relay { id: existing.id, + tenant: existing.tenant, name: payload.name, subdomain: payload.subdomain.clone(), schema: payload.subdomain.replace('-', "_"), @@ -353,9 +367,10 @@ async fn update_tenant_relay( description: payload.description, plan: payload.plan, status: existing.status, + config: payload.config, }; - if let Err(err) = state.repo.update_relay(&updated).await { + if let Err(err) = state.repo.upsert_relay(&relay).await { if is_unique_subdomain_violation(&err) { return ( StatusCode::CONFLICT, @@ -375,7 +390,16 @@ async fn update_tenant_relay( .into_response(); } - (StatusCode::OK, Json(updated)).into_response() + if let Err(err) = state.provisioner.sync_relay(&relay, false).await { + tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { error: format!("failed to update relay config: {err}") }), + ) + .into_response(); + } + + (StatusCode::OK, Json(relay)).into_response() } async fn deactivate_tenant_relay( @@ -408,18 +432,13 @@ async fn deactivate_tenant_relay( return forbidden(); } - let updated = UpdateRelay { - id: existing.id, - name: existing.name, - subdomain: existing.subdomain.clone(), - schema: existing.subdomain.replace('-', "_"), - icon: existing.icon, - description: existing.description, - plan: existing.plan, + let relay = Relay { status: "deactivated".to_string(), + config: None, + ..existing }; - if let Err(_) = state.repo.update_relay(&updated).await { + if let Err(_) = state.repo.upsert_relay(&relay).await { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { @@ -429,7 +448,7 @@ async fn deactivate_tenant_relay( .into_response(); } - (StatusCode::OK, Json(updated)).into_response() + (StatusCode::OK, Json(relay)).into_response() } async fn list_tenant_invoices( @@ -734,8 +753,9 @@ async fn admin_update_relay( } }; - let updated = UpdateRelay { + let relay = Relay { id: existing.id, + tenant: existing.tenant, name: payload.name, subdomain: payload.subdomain.clone(), schema: payload.subdomain.replace('-', "_"), @@ -743,9 +763,10 @@ async fn admin_update_relay( description: payload.description, plan: payload.plan, status: existing.status, + config: payload.config, }; - if let Err(err) = state.repo.update_relay(&updated).await { + if let Err(err) = state.repo.upsert_relay(&relay).await { if is_unique_subdomain_violation(&err) { return ( StatusCode::CONFLICT, @@ -765,7 +786,16 @@ async fn admin_update_relay( .into_response(); } - (StatusCode::OK, Json(updated)).into_response() + if let Err(err) = state.provisioner.sync_relay(&relay, false).await { + tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { error: format!("failed to update relay config: {err}") }), + ) + .into_response(); + } + + (StatusCode::OK, Json(relay)).into_response() } async fn admin_deactivate_relay( @@ -798,18 +828,13 @@ async fn admin_deactivate_relay( } }; - let updated = UpdateRelay { - id: existing.id, - name: existing.name, - subdomain: existing.subdomain.clone(), - schema: existing.subdomain.replace('-', "_"), - icon: existing.icon, - description: existing.description, - plan: existing.plan, + let relay = Relay { status: "deactivated".to_string(), + config: None, + ..existing }; - if let Err(_) = state.repo.update_relay(&updated).await { + if let Err(_) = state.repo.upsert_relay(&relay).await { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { @@ -819,25 +844,7 @@ async fn admin_deactivate_relay( .into_response(); } - (StatusCode::OK, Json(updated)).into_response() + (StatusCode::OK, Json(relay)).into_response() } -fn spawn_provisioning_worker(repo: Repo, provisioner: Provisioner, relay: NewRelay) { - tokio::spawn(async move { - tracing::info!(relay_id = relay.id, "provisioning worker started"); - if let Err(err) = provisioner.provision_relay(&relay).await { - tracing::error!(relay_id = relay.id, error = %err, "provisioning failed"); - if let Err(err) = repo - .update_relay_status(&relay.id, "provisioning_failed") - .await - { - tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status"); - } - return; - } - if let Err(err) = repo.update_relay_status(&relay.id, "active").await { - tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status"); - } - }); -} diff --git a/backend/src/models.rs b/backend/src/models.rs index 96b1896..da35d42 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -1,5 +1,14 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayConfig { + pub policy: Option, + pub groups: Option, + pub management: Option, + pub blossom: Option, + pub push: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Tenant { pub pubkey: String, @@ -14,7 +23,7 @@ pub struct NewTenant { pub tenant_nwc_url: String, } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Relay { pub id: String, pub tenant: String, @@ -25,31 +34,7 @@ pub struct Relay { pub description: String, pub plan: String, pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NewRelay { - pub id: String, - pub tenant: String, - pub name: String, - pub subdomain: String, - pub schema: String, - pub icon: String, - pub description: String, - pub plan: String, - pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateRelay { - pub id: String, - pub name: String, - pub subdomain: String, - pub schema: String, - pub icon: String, - pub description: String, - pub plan: String, - pub status: String, + pub config: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/src/provisioning.rs b/backend/src/provisioning.rs index 3f734c2..56cb741 100644 --- a/backend/src/provisioning.rs +++ b/backend/src/provisioning.rs @@ -3,12 +3,13 @@ use rand::RngCore; use rand::rngs::OsRng; use reqwest::Client; use serde::Serialize; +use serde_json::{Value, json}; use nostr_sdk::nostr::Keys; use nostr_sdk::nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::nostr::types::url::Url; -use crate::models::NewRelay; +use crate::models::{Relay, RelayConfig}; #[derive(Clone)] pub struct Provisioner { @@ -35,31 +36,49 @@ impl Provisioner { }) } - pub async fn provision_relay(&self, relay: &NewRelay) -> Result<()> { - let host = format!("{}.{}", relay.subdomain, self.relay_domain); - let secret = generate_secret_hex(); - - let payload = ZooidConfig::new(relay, host, secret); + /// Create or update a relay in zooid. + /// + /// On creation, POSTs the full config (including a generated secret and host). + /// On update, PATCHes only the mutable fields (info + config sections). + pub async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> { let url = format!("{}/relay/{}", self.base_url.trim_end_matches('/'), relay.id); - let auth = self.build_auth_header(&url, HttpMethod::POST).await?; - let res = self - .client - .post(&url) - .header(reqwest::header::AUTHORIZATION, auth) - .json(&payload) - .send() - .await?; + if is_new { + let host = format!("{}.{}", relay.subdomain, self.relay_domain); + let secret = generate_secret_hex(); + let payload = build_full_config(relay, host, secret); + let auth = self.build_auth_header(&url, HttpMethod::POST).await?; - if !res.status().is_success() { - let status = res.status(); - let body = res.text().await.unwrap_or_default(); - return Err(anyhow!( - "zooid provisioning failed for {}: {} {}", - url, - status, - body - )); + let res = self + .client + .post(&url) + .header(reqwest::header::AUTHORIZATION, auth) + .json(&payload) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(anyhow!("zooid create failed: {} {}", status, body)); + } + } else { + let patch = build_patch(relay); + let auth = self.build_auth_header(&url, HttpMethod::PATCH).await?; + + let res = self + .client + .patch(&url) + .header(reqwest::header::AUTHORIZATION, auth) + .json(&patch) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(anyhow!("zooid patch failed: {} {}", status, body)); + } } Ok(()) @@ -73,108 +92,86 @@ impl Provisioner { } } -#[derive(Debug, Serialize)] -struct ZooidConfig { - host: String, - schema: String, - secret: String, - info: ZooidInfo, - policy: ZooidPolicy, - groups: ZooidGroups, - push: ZooidPush, - management: ZooidManagement, - blossom: ZooidBlossom, - roles: ZooidRoles, +/// Builds the full zooid config payload for relay creation (POST). +fn build_full_config(relay: &Relay, host: String, secret: String) -> Value { + let blossom_default = relay.plan != "free"; + let cfg = relay.config.as_ref(); + + json!({ + "host": host, + "schema": relay.schema, + "secret": secret, + "info": { + "name": relay.name, + "icon": relay.icon, + "pubkey": relay.tenant, + "description": relay.description, + }, + "policy": { + "public_join": cfg_bool(cfg, |c| &c.policy, "public_join", false), + "strip_signatures": cfg_bool(cfg, |c| &c.policy, "strip_signatures", false), + }, + "groups": { + "enabled": cfg_bool(cfg, |c| &c.groups, "enabled", true), + "auto_join": cfg_bool(cfg, |c| &c.groups, "auto_join", true), + }, + "push": { + "enabled": cfg_bool(cfg, |c| &c.push, "enabled", true), + }, + "management": { + "enabled": cfg_bool(cfg, |c| &c.management, "enabled", true), + }, + "blossom": { + "enabled": cfg_bool(cfg, |c| &c.blossom, "enabled", blossom_default), + }, + "roles": { + "member": { "pubkeys": [], "can_invite": true, "can_manage": false } + }, + }) } -impl ZooidConfig { - fn new(relay: &NewRelay, host: String, secret: String) -> Self { - let blossom_enabled = relay.plan != "free"; +/// Builds the partial zooid patch payload for relay updates (PATCH). +fn build_patch(relay: &Relay) -> Value { + let blossom_default = relay.plan != "free"; + let cfg = relay.config.as_ref(); - Self { - host, - schema: relay.schema.clone(), - secret, - info: ZooidInfo { - name: relay.name.clone(), - icon: relay.icon.clone(), - pubkey: relay.tenant.clone(), - description: relay.description.clone(), - }, - policy: ZooidPolicy { - public_join: false, - strip_signatures: false, - }, - groups: ZooidGroups { - enabled: true, - auto_join: true, - }, - push: ZooidPush { enabled: true }, - management: ZooidManagement { - enabled: true, - methods: Vec::new(), - }, - blossom: ZooidBlossom { - enabled: blossom_enabled, - }, - roles: ZooidRoles { - member: ZooidRole { - pubkeys: Vec::new(), - can_invite: true, - can_manage: false, - }, - }, - } - } + json!({ + "info": { + "name": relay.name, + "icon": relay.icon, + "description": relay.description, + }, + "policy": { + "public_join": cfg_bool(cfg, |c| &c.policy, "public_join", false), + "strip_signatures": cfg_bool(cfg, |c| &c.policy, "strip_signatures", false), + }, + "groups": { + "enabled": cfg_bool(cfg, |c| &c.groups, "enabled", true), + "auto_join": cfg_bool(cfg, |c| &c.groups, "auto_join", true), + }, + "push": { + "enabled": cfg_bool(cfg, |c| &c.push, "enabled", true), + }, + "management": { + "enabled": cfg_bool(cfg, |c| &c.management, "enabled", true), + }, + "blossom": { + "enabled": cfg_bool(cfg, |c| &c.blossom, "enabled", blossom_default), + }, + }) } -#[derive(Debug, Serialize)] -struct ZooidInfo { - name: String, - icon: String, - pubkey: String, - description: String, +fn cfg_bool( + cfg: Option<&RelayConfig>, + section: impl Fn(&RelayConfig) -> &Option, + key: &str, + default: bool, +) -> bool { + cfg.and_then(|c| section(c).as_ref()) + .and_then(|v| v[key].as_bool()) + .unwrap_or(default) } -#[derive(Debug, Serialize)] -struct ZooidPolicy { - public_join: bool, - strip_signatures: bool, -} - -#[derive(Debug, Serialize)] -struct ZooidGroups { - enabled: bool, - auto_join: bool, -} - -#[derive(Debug, Serialize)] -struct ZooidPush { - enabled: bool, -} - -#[derive(Debug, Serialize)] -struct ZooidManagement { - enabled: bool, - methods: Vec, -} - -#[derive(Debug, Serialize)] -struct ZooidBlossom { - enabled: bool, -} - -#[derive(Debug, Serialize)] -struct ZooidRoles { - member: ZooidRole, -} - -#[derive(Debug, Serialize)] -struct ZooidRole { - pubkeys: Vec, - can_invite: bool, - can_manage: bool, -} fn generate_secret_hex() -> String { let mut bytes = [0u8; 32]; diff --git a/backend/src/repo.rs b/backend/src/repo.rs index 3928745..9c85525 100644 --- a/backend/src/repo.rs +++ b/backend/src/repo.rs @@ -1,11 +1,27 @@ use anyhow::Result; -use sqlx::{Sqlite, SqlitePool, Transaction}; +use sqlx::{Row, Sqlite, SqlitePool, Transaction}; use crate::models::{ - Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewRelay, NewTenant, Relay, Tenant, - UpdateRelay, + Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewTenant, Relay, Tenant, }; +fn relay_from_row(row: sqlx::sqlite::SqliteRow) -> Relay { + let config_json: Option = row.get("config"); + let config = config_json.and_then(|s| serde_json::from_str(&s).ok()); + Relay { + id: row.get("id"), + tenant: row.get("tenant"), + name: row.get("name"), + subdomain: row.get("subdomain"), + schema: row.get("schema"), + icon: row.get("icon"), + description: row.get("description"), + plan: row.get("plan"), + status: row.get("status"), + config, + } +} + #[derive(Clone)] pub struct Repo { pool: SqlitePool, @@ -75,10 +91,20 @@ impl Repo { Ok(()) } - pub async fn create_relay(&self, relay: &NewRelay) -> Result<()> { + pub async fn upsert_relay(&self, relay: &Relay) -> Result<()> { + let config_json = relay.config.as_ref().map(serde_json::to_string).transpose()?; sqlx::query( - "INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + subdomain = excluded.subdomain, + schema = excluded.schema, + icon = excluded.icon, + description = excluded.description, + plan = excluded.plan, + status = excluded.status, + config = excluded.config", ) .bind(&relay.id) .bind(&relay.tenant) @@ -89,24 +115,7 @@ impl Repo { .bind(&relay.description) .bind(&relay.plan) .bind(&relay.status) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn update_relay(&self, relay: &UpdateRelay) -> Result<()> { - sqlx::query( - "UPDATE relays SET name = ?, subdomain = ?, schema = ?, icon = ?, description = ?, plan = ?, status = ? - WHERE id = ?", - ) - .bind(&relay.name) - .bind(&relay.subdomain) - .bind(&relay.schema) - .bind(&relay.icon) - .bind(&relay.description) - .bind(&relay.plan) - .bind(&relay.status) - .bind(&relay.id) + .bind(config_json) .execute(&self.pool) .await?; Ok(()) @@ -132,32 +141,32 @@ impl Repo { } pub async fn get_relay(&self, id: &str) -> Result> { - let relay = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE id = ?", + let row = sqlx::query( + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) .await?; - Ok(relay) + Ok(row.map(relay_from_row)) } pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result> { - let relays = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE tenant = ? ORDER BY name", + let rows = sqlx::query( + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE tenant = ? ORDER BY name", ) .bind(tenant) .fetch_all(&self.pool) .await?; - Ok(relays) + Ok(rows.into_iter().map(relay_from_row).collect()) } pub async fn list_relays(&self) -> Result> { - let relays = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays ORDER BY name", + let rows = sqlx::query( + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays ORDER BY name", ) .fetch_all(&self.pool) .await?; - Ok(relays) + Ok(rows.into_iter().map(relay_from_row).collect()) } pub async fn create_invoice_with_items( diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx new file mode 100644 index 0000000..3dca539 --- /dev/null +++ b/frontend/src/components/Checkbox.tsx @@ -0,0 +1,13 @@ +export default function Checkbox(props: { label: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} diff --git a/frontend/src/components/RelayDetailCard.tsx b/frontend/src/components/RelayDetailCard.tsx new file mode 100644 index 0000000..84208a6 --- /dev/null +++ b/frontend/src/components/RelayDetailCard.tsx @@ -0,0 +1,129 @@ +import { For, Show } from "solid-js" +import type { Relay } from "../lib/api" + +function Field(props: { label: string; children: any }) { + return ( +
+
{props.label}
+
{props.children}
+
+ ) +} + +function Badge(props: { on: boolean; onLabel?: string; offLabel?: string }) { + const label = () => (props.on ? (props.onLabel ?? "Yes") : (props.offLabel ?? "No")) + return ( + + {label()} + + ) +} + +function Section(props: { title: string; children: any }) { + return ( +
+

{props.title}

+
+ {props.children} +
+
+ ) +} + +type RelayDetailCardProps = { + relay: Relay + showTenant?: boolean +} + +export default function RelayDetailCard(props: RelayDetailCardProps) { + const r = () => props.relay + const cfg = () => r().config + + return ( +
+ {/* Header */} +
+ + + +
+

{r().name}

+ + wss://{r().subdomain}.spaces.coracle.social + + +

{r().description}

+
+
+
+ +
+ + {/* Relay info */} +
+ + {r().plan} + + + + {r().status} + + + {r().schema} + + + {r().tenant} + + +
+ +
+ + {/* Config sections */} + +
+ + + + + + +
+ +
+ +
+ + + + + + +
+ +
+ +
+ + + + +
+ +
+ +
+ + + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/RelayForm.tsx b/frontend/src/components/RelayForm.tsx index 474927c..34a1a9d 100644 --- a/frontend/src/components/RelayForm.tsx +++ b/frontend/src/components/RelayForm.tsx @@ -1,3 +1,6 @@ +import type { RelayConfig } from "../lib/api" +import Checkbox from "./Checkbox" + type RelayFormProps = { name: string setName: (value: string) => void @@ -10,6 +13,8 @@ type RelayFormProps = { plan: string setPlan: (value: string) => void plans: readonly string[] + config: RelayConfig + setConfig: (value: RelayConfig) => void onSubmit: (e: Event) => void submitting: boolean error?: string @@ -18,8 +23,13 @@ type RelayFormProps = { } export default function RelayForm(props: RelayFormProps) { + function patchConfig(patch: Partial) { + props.setConfig({ ...props.config, ...patch }) + } + return ( -
+ + {/* Basic info */}
+ + {/* Policy */} +
+ Policy + patchConfig({ policy: { ...props.config.policy, public_join: v } })} + /> + patchConfig({ policy: { ...props.config.policy, strip_signatures: v } })} + /> +
+ + {/* Groups (NIP 29) */} +
+ Groups (NIP 29) + patchConfig({ groups: { ...props.config.groups, enabled: v } })} + /> + patchConfig({ groups: { ...props.config.groups, auto_join: v } })} + /> +
+ + {/* Management (NIP 86) */} +
+ Management (NIP 86) + patchConfig({ management: { enabled: v } })} + /> +
+ + {/* Blossom */} +
+ Blossom + patchConfig({ blossom: { enabled: v } })} + /> +
+ + {/* Push (NIP 9a) */} +
+ Push (NIP 9a) + patchConfig({ push: { enabled: v } })} + /> +
+ {props.error &&

{props.error}

}