Stabilize id/schema

This commit is contained in:
Jon Staab
2026-03-03 10:16:09 -08:00
parent 46a270513e
commit 7e577bf7ff
10 changed files with 119 additions and 135 deletions
+5 -3
View File
@@ -1,17 +1,19 @@
CREATE TABLE IF NOT EXISTS tenants ( CREATE TABLE IF NOT EXISTS tenants (
pubkey TEXT PRIMARY KEY, pubkey TEXT PRIMARY KEY,
status TEXT NOT NULL status TEXT NOT NULL,
tenant_nwc_url TEXT NOT NULL DEFAULT ""
); );
CREATE TABLE IF NOT EXISTS relays ( CREATE TABLE IF NOT EXISTS relays (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant TEXT NOT NULL, tenant TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
subdomain TEXT NOT NULL, subdomain TEXT NOT NULL UNIQUE,
schema TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
plan TEXT NOT NULL, plan TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT "",
config TEXT,
FOREIGN KEY (tenant) REFERENCES tenants(pubkey) FOREIGN KEY (tenant) REFERENCES tenants(pubkey)
); );
@@ -1 +0,0 @@
ALTER TABLE relays ADD COLUMN icon TEXT NOT NULL DEFAULT "";
@@ -1 +0,0 @@
ALTER TABLE tenants ADD COLUMN tenant_nwc_url TEXT NOT NULL DEFAULT "";
@@ -1,2 +0,0 @@
CREATE UNIQUE INDEX IF NOT EXISTS relays_subdomain_unique
ON relays (subdomain);
@@ -1 +0,0 @@
ALTER TABLE relays ADD COLUMN config TEXT;
+8 -11
View File
@@ -9,7 +9,6 @@ use axum::{
routing::{get, post, put}, routing::{get, post, put},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::verify_nip98; use crate::auth::verify_nip98;
use crate::models::{NewTenant, Relay, RelayConfig}; use crate::models::{NewTenant, Relay, RelayConfig};
@@ -242,12 +241,12 @@ async fn create_tenant_relay(
.into_response(); .into_response();
} }
let id = payload.subdomain.replace('-', "_");
let relay = Relay { let relay = Relay {
id: Uuid::new_v4().to_string(), id: id.clone(),
tenant: pubkey.clone(), tenant: pubkey.clone(),
name: payload.name, name: payload.name,
subdomain: payload.subdomain.clone(), subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
icon: payload.icon, icon: payload.icon,
description: payload.description, description: payload.description,
plan: payload.plan, plan: payload.plan,
@@ -275,7 +274,7 @@ async fn create_tenant_relay(
.into_response(); .into_response();
} }
if let Err(err) = state.provisioner.sync_relay(&relay, true).await { if let Err(err) = state.provisioner.create_relay(&relay).await {
tracing::error!(relay_id = relay.id, error = %err, "zooid create failed"); tracing::error!(relay_id = relay.id, error = %err, "zooid create failed");
let _ = state.repo.update_relay_status(&relay.id, "provisioning_failed").await; let _ = state.repo.update_relay_status(&relay.id, "provisioning_failed").await;
return ( return (
@@ -362,7 +361,6 @@ async fn update_tenant_relay(
tenant: existing.tenant, tenant: existing.tenant,
name: payload.name, name: payload.name,
subdomain: payload.subdomain.clone(), subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
icon: payload.icon, icon: payload.icon,
description: payload.description, description: payload.description,
plan: payload.plan, plan: payload.plan,
@@ -390,15 +388,17 @@ async fn update_tenant_relay(
.into_response(); .into_response();
} }
if let Err(err) = state.provisioner.sync_relay(&relay, false).await { if let Err(err) = state.provisioner.update_relay(&relay).await {
tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed"); tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed");
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { error: format!("failed to update relay config: {err}") }), Json(ApiError { error: format!("failed to provision relay: {err}") }),
) )
.into_response(); .into_response();
} }
let _ = state.repo.update_relay_status(&relay.id, "active").await;
(StatusCode::OK, Json(relay)).into_response() (StatusCode::OK, Json(relay)).into_response()
} }
@@ -758,7 +758,6 @@ async fn admin_update_relay(
tenant: existing.tenant, tenant: existing.tenant,
name: payload.name, name: payload.name,
subdomain: payload.subdomain.clone(), subdomain: payload.subdomain.clone(),
schema: payload.subdomain.replace('-', "_"),
icon: payload.icon, icon: payload.icon,
description: payload.description, description: payload.description,
plan: payload.plan, plan: payload.plan,
@@ -786,7 +785,7 @@ async fn admin_update_relay(
.into_response(); .into_response();
} }
if let Err(err) = state.provisioner.sync_relay(&relay, false).await { if let Err(err) = state.provisioner.update_relay(&relay).await {
tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed"); tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed");
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -846,5 +845,3 @@ async fn admin_deactivate_relay(
(StatusCode::OK, Json(relay)).into_response() (StatusCode::OK, Json(relay)).into_response()
} }
-1
View File
@@ -29,7 +29,6 @@ pub struct Relay {
pub tenant: String, pub tenant: String,
pub name: String, pub name: String,
pub subdomain: String, pub subdomain: String,
pub schema: String,
pub icon: String, pub icon: String,
pub description: String, pub description: String,
pub plan: String, pub plan: String,
+100 -106
View File
@@ -2,7 +2,6 @@ use anyhow::{Result, anyhow};
use rand::RngCore; use rand::RngCore;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use reqwest::Client; use reqwest::Client;
use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use nostr_sdk::nostr::Keys; use nostr_sdk::nostr::Keys;
@@ -36,49 +35,113 @@ impl Provisioner {
}) })
} }
/// Create or update a relay in zooid. /// Create a relay in zooid.
/// ///
/// On creation, POSTs the full config (including a generated secret and host). /// POSTs the full config (including a generated secret and host).
/// On update, PATCHes only the mutable fields (info + config sections). pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
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 url = format!("{}/relay/{}", self.base_url.trim_end_matches('/'), relay.id);
if is_new { let blossom_default = relay.plan != "free";
let host = format!("{}.{}", relay.subdomain, self.relay_domain); let cfg = relay.config.as_ref();
let secret = generate_secret_hex(); let host = format!("{}.{}", relay.subdomain, self.relay_domain);
let payload = build_full_config(relay, host, secret); let secret = generate_secret_hex();
let auth = self.build_auth_header(&url, HttpMethod::POST).await?; let payload = json!({
"host": host,
"schema": relay.id,
"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 }
},
});
let auth = self.build_auth_header(&url, HttpMethod::POST).await?;
let res = self let res = self
.client .client
.post(&url) .post(&url)
.header(reqwest::header::AUTHORIZATION, auth) .header(reqwest::header::AUTHORIZATION, auth)
.json(&payload) .json(&payload)
.send() .send()
.await?; .await?;
if !res.status().is_success() { if !res.status().is_success() {
let status = res.status(); let status = res.status();
let body = res.text().await.unwrap_or_default(); let body = res.text().await.unwrap_or_default();
return Err(anyhow!("zooid create failed: {} {}", status, body)); 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 Ok(())
.client }
.patch(&url)
.header(reqwest::header::AUTHORIZATION, auth)
.json(&patch)
.send()
.await?;
if !res.status().is_success() { /// Update a relay in zooid.
let status = res.status(); ///
let body = res.text().await.unwrap_or_default(); /// PATCHes only the mutable fields (info + config sections).
return Err(anyhow!("zooid patch failed: {} {}", status, body)); pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
} let url = format!("{}/relay/{}", self.base_url.trim_end_matches('/'), relay.id);
let host = format!("{}.{}", relay.subdomain, self.relay_domain);
let blossom_default = relay.plan != "free";
let cfg = relay.config.as_ref();
let patch = json!({
"host": host,
"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),
},
});
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(()) Ok(())
@@ -92,75 +155,6 @@ impl Provisioner {
} }
} }
/// 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 }
},
})
}
/// 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();
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),
},
})
}
fn cfg_bool( fn cfg_bool(
cfg: Option<&RelayConfig>, cfg: Option<&RelayConfig>,
section: impl Fn(&RelayConfig) -> &Option<Value>, section: impl Fn(&RelayConfig) -> &Option<Value>,
+5 -8
View File
@@ -13,7 +13,6 @@ fn relay_from_row(row: sqlx::sqlite::SqliteRow) -> Relay {
tenant: row.get("tenant"), tenant: row.get("tenant"),
name: row.get("name"), name: row.get("name"),
subdomain: row.get("subdomain"), subdomain: row.get("subdomain"),
schema: row.get("schema"),
icon: row.get("icon"), icon: row.get("icon"),
description: row.get("description"), description: row.get("description"),
plan: row.get("plan"), plan: row.get("plan"),
@@ -94,12 +93,11 @@ impl Repo {
pub async fn upsert_relay(&self, relay: &Relay) -> Result<()> { pub async fn upsert_relay(&self, relay: &Relay) -> Result<()> {
let config_json = relay.config.as_ref().map(serde_json::to_string).transpose()?; let config_json = relay.config.as_ref().map(serde_json::to_string).transpose()?;
sqlx::query( sqlx::query(
"INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status, config) "INSERT INTO relays (id, tenant, name, subdomain, icon, description, plan, status, config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
subdomain = excluded.subdomain, subdomain = excluded.subdomain,
schema = excluded.schema,
icon = excluded.icon, icon = excluded.icon,
description = excluded.description, description = excluded.description,
plan = excluded.plan, plan = excluded.plan,
@@ -110,7 +108,6 @@ impl Repo {
.bind(&relay.tenant) .bind(&relay.tenant)
.bind(&relay.name) .bind(&relay.name)
.bind(&relay.subdomain) .bind(&relay.subdomain)
.bind(&relay.schema)
.bind(&relay.icon) .bind(&relay.icon)
.bind(&relay.description) .bind(&relay.description)
.bind(&relay.plan) .bind(&relay.plan)
@@ -142,7 +139,7 @@ impl Repo {
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> { pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE id = ?", "SELECT id, tenant, name, subdomain, icon, description, plan, status, config FROM relays WHERE id = ?",
) )
.bind(id) .bind(id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -152,7 +149,7 @@ impl Repo {
pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result<Vec<Relay>> { pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE tenant = ? ORDER BY name", "SELECT id, tenant, name, subdomain, icon, description, plan, status, config FROM relays WHERE tenant = ? ORDER BY name",
) )
.bind(tenant) .bind(tenant)
.fetch_all(&self.pool) .fetch_all(&self.pool)
@@ -162,7 +159,7 @@ impl Repo {
pub async fn list_relays(&self) -> Result<Vec<Relay>> { pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays ORDER BY name", "SELECT id, tenant, name, subdomain, icon, description, plan, status, config FROM relays ORDER BY name",
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
+1 -1
View File
@@ -1,6 +1,6 @@
dev: dev:
#!/usr/bin/env sh #!/usr/bin/env sh
trap 'kill 0' EXIT trap 'kill 0' EXIT
cd backend && RUST_LOG=backend=info cargo run & cd backend && onchange src -ik -- bash -c 'RUST_LOG=backend=info cargo run' &
cd frontend && bun dev & cd frontend && bun dev &
wait wait