Simplify relay upsert

This commit is contained in:
Jon Staab
2026-03-03 09:08:54 -08:00
parent 6618025b54
commit 46a270513e
13 changed files with 495 additions and 242 deletions
@@ -0,0 +1 @@
ALTER TABLE relays ADD COLUMN config TEXT;
+55 -48
View File
@@ -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<RelayConfig>,
}
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<RelayConfig>,
}
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");
}
});
}
+11 -26
View File
@@ -1,5 +1,14 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayConfig {
pub policy: Option<serde_json::Value>,
pub groups: Option<serde_json::Value>,
pub management: Option<serde_json::Value>,
pub blossom: Option<serde_json::Value>,
pub push: Option<serde_json::Value>,
}
#[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<RelayConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
+115 -118
View File
@@ -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<Value>,
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<String>,
}
#[derive(Debug, Serialize)]
struct ZooidBlossom {
enabled: bool,
}
#[derive(Debug, Serialize)]
struct ZooidRoles {
member: ZooidRole,
}
#[derive(Debug, Serialize)]
struct ZooidRole {
pubkeys: Vec<String>,
can_invite: bool,
can_manage: bool,
}
fn generate_secret_hex() -> String {
let mut bytes = [0u8; 32];
+42 -33
View File
@@ -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<String> = 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<Option<Relay>> {
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<Vec<Relay>> {
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<Vec<Relay>> {
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(
+13
View File
@@ -0,0 +1,13 @@
export default function Checkbox(props: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={props.checked}
onChange={e => props.onChange(e.currentTarget.checked)}
class="w-4 h-4 rounded border-gray-300 text-blue-600"
/>
<span class="text-sm text-gray-700">{props.label}</span>
</label>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { For, Show } from "solid-js"
import type { Relay } from "../lib/api"
function Field(props: { label: string; children: any }) {
return (
<div>
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">{props.label}</dt>
<dd class="mt-0.5 text-sm text-gray-900">{props.children}</dd>
</div>
)
}
function Badge(props: { on: boolean; onLabel?: string; offLabel?: string }) {
const label = () => (props.on ? (props.onLabel ?? "Yes") : (props.offLabel ?? "No"))
return (
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${props.on ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}`}>
{label()}
</span>
)
}
function Section(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">{props.title}</h3>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
{props.children}
</dl>
</div>
)
}
type RelayDetailCardProps = {
relay: Relay
showTenant?: boolean
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const cfg = () => r().config
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-start gap-4">
<Show when={r().icon}>
<img src={r().icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900">{r().name}</h1>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={r().description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().description}</p>
</Show>
</div>
</div>
<hr class="border-gray-200" />
{/* Relay info */}
<Section title="Relay">
<Field label="Plan">
<span class="capitalize">{r().plan}</span>
</Field>
<Field label="Status">
<span class={`capitalize font-medium ${r().status === "active" ? "text-green-600" : r().status === "deactivated" ? "text-red-500" : "text-yellow-600"}`}>
{r().status}
</span>
</Field>
<Field label="Schema">{r().schema}</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant}</span>
</Field>
</Show>
</Section>
<hr class="border-gray-200" />
{/* Config sections */}
<Show when={cfg()}>
<Section title="Policy">
<Field label="Public join">
<Badge on={cfg().policy.public_join} onLabel="Enabled" offLabel="Disabled" />
</Field>
<Field label="Strip signatures">
<Badge on={cfg().policy.strip_signatures} onLabel="Enabled" offLabel="Disabled" />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Groups (NIP 29)">
<Field label="Enabled">
<Badge on={cfg().groups.enabled} />
</Field>
<Field label="Auto-join">
<Badge on={cfg().groups.auto_join} />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Management (NIP 86)">
<Field label="Enabled">
<Badge on={cfg().management.enabled} />
</Field>
</Section>
<hr class="border-gray-200" />
<Section title="Features">
<Field label="Blossom">
<Badge on={cfg().blossom.enabled} onLabel="Enabled" offLabel="Disabled" />
</Field>
<Field label="Push (NIP 9a)">
<Badge on={cfg().push.enabled} onLabel="Enabled" offLabel="Disabled" />
</Field>
</Section>
</Show>
</div>
)
}
+72 -1
View File
@@ -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<RelayConfig>) {
props.setConfig({ ...props.config, ...patch })
}
return (
<form onSubmit={props.onSubmit} class="space-y-4">
<form onSubmit={props.onSubmit} class="space-y-6">
{/* Basic info */}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Relay Name</label>
<input
@@ -73,6 +83,67 @@ export default function RelayForm(props: RelayFormProps) {
))}
</div>
</div>
{/* Policy */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Policy</legend>
<Checkbox
label="Allow public join (anyone can join without an invite)"
checked={props.config.policy.public_join}
onChange={v => patchConfig({ policy: { ...props.config.policy, public_join: v } })}
/>
<Checkbox
label="Strip signatures when serving events to non-admins"
checked={props.config.policy.strip_signatures}
onChange={v => patchConfig({ policy: { ...props.config.policy, strip_signatures: v } })}
/>
</fieldset>
{/* Groups (NIP 29) */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Groups (NIP 29)</legend>
<Checkbox
label="Enable NIP 29 groups"
checked={props.config.groups.enabled}
onChange={v => patchConfig({ groups: { ...props.config.groups, enabled: v } })}
/>
<Checkbox
label="Allow members to join groups without approval"
checked={props.config.groups.auto_join}
onChange={v => patchConfig({ groups: { ...props.config.groups, auto_join: v } })}
/>
</fieldset>
{/* Management (NIP 86) */}
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
<legend class="text-sm font-semibold text-gray-700 px-1">Management (NIP 86)</legend>
<Checkbox
label="Enable NIP 86 relay management"
checked={props.config.management.enabled}
onChange={v => patchConfig({ management: { enabled: v } })}
/>
</fieldset>
{/* Blossom */}
<fieldset class="border border-gray-200 rounded-lg p-4">
<legend class="text-sm font-semibold text-gray-700 px-1">Blossom</legend>
<Checkbox
label="Enable Blossom media storage"
checked={props.config.blossom.enabled}
onChange={v => patchConfig({ blossom: { enabled: v } })}
/>
</fieldset>
{/* Push (NIP 9a) */}
<fieldset class="border border-gray-200 rounded-lg p-4">
<legend class="text-sm font-semibold text-gray-700 px-1">Push (NIP 9a)</legend>
<Checkbox
label="Enable NIP 9a push notifications"
checked={props.config.push.enabled}
onChange={v => patchConfig({ push: { enabled: v } })}
/>
</fieldset>
{props.error && <p class="text-sm text-red-600">{props.error}</p>}
<button
type="submit"
+24
View File
@@ -87,6 +87,27 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>
}
export type RelayConfig = {
policy: {
public_join: boolean
strip_signatures: boolean
}
groups: {
enabled: boolean
auto_join: boolean
}
management: {
enabled: boolean
}
blossom: {
enabled: boolean
}
push: {
enabled: boolean
}
}
export type Relay = {
id: string
tenant: string
@@ -97,6 +118,7 @@ export type Relay = {
description: string
plan: string
status: string
config: RelayConfig
}
export type Tenant = {
@@ -125,6 +147,7 @@ export type UpdateRelayInput = {
icon: string
description: string
plan: string
config: RelayConfig
}
export type AdminCheck = {
@@ -145,6 +168,7 @@ export type CreateRelayInput = {
icon: string
description: string
plan: string
config: RelayConfig
}
export function createTenantRelay(input: CreateRelayInput) {
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
import { adminDeactivateRelay, adminGetRelay } from "../../lib/api"
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"
@@ -43,14 +44,7 @@ export default function AdminRelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-1 py-2">{r().name}</h1>
<p class="text-sm text-gray-500">{r().subdomain}.spaces.coracle.social</p>
<p class="text-sm text-gray-500 mt-2 break-all">Tenant: {r().tenant}</p>
<p class="text-sm text-gray-700 mt-2">Plan: <span class="uppercase">{r().plan}</span></p>
<p class="text-sm text-gray-700">Status: <span class="uppercase">{r().status}</span></p>
<Show when={r().description.trim()}>
<p class="mt-3 text-gray-700">{r().description}</p>
</Show>
<RelayDetailCard relay={r()} showTenant />
</div>
)}
</Show>
+14 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show, createEffect, createResource, createSignal } from "solid-js"
import { adminGetRelay, adminUpdateRelay } from "../../lib/api"
import { adminGetRelay, adminUpdateRelay, type RelayConfig } from "../../lib/api"
import RelayForm from "../../components/RelayForm"
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
import { slugify } from "../../lib/slugify"
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
import useMinLoading from "../../components/useMinLoading"
const DEFAULT_CONFIG: RelayConfig = {
policy: { public_join: false, strip_signatures: false },
groups: { enabled: false, auto_join: false },
management: { enabled: false },
blossom: { enabled: false },
push: { enabled: false },
}
export default function AdminRelayEdit() {
const navigate = useNavigate()
const params = useParams()
@@ -21,6 +29,7 @@ export default function AdminRelayEdit() {
const [icon, setIcon] = createSignal("")
const [description, setDescription] = createSignal("")
const [plan, setPlan] = createSignal<RelayPlanId>("free")
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -32,6 +41,7 @@ export default function AdminRelayEdit() {
setIcon(data.icon)
setDescription(data.description)
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
setConfig(data.config ?? DEFAULT_CONFIG)
})
async function handleSubmit(e: Event) {
@@ -45,6 +55,7 @@ export default function AdminRelayEdit() {
icon: icon().trim(),
description: description().trim(),
plan: plan(),
config: config(),
})
navigate(`/admin/relays/${relayId()}`)
} catch (e) {
@@ -79,6 +90,8 @@ export default function AdminRelayEdit() {
plan={plan()}
setPlan={setPlan}
plans={RELAY_PLAN_IDS}
config={config()}
setConfig={setConfig}
onSubmit={handleSubmit}
submitting={submitting()}
error={error()}
+3 -6
View File
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
import { deactivateTenantRelay, getTenantRelay } from "../../lib/api"
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"
@@ -41,13 +42,9 @@ export default function RelayDetail() {
/>
<Show when={!loading() && relay()}>
{(loadedRelay) => (
{(r) => (
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 py-2">{loadedRelay().name}</h1>
<p class="mt-1 text-sm text-gray-500">https://{loadedRelay().subdomain}.spaces.coracle.social</p>
<Show when={loadedRelay().description.trim()}>
<p class="mt-3 text-gray-700">{loadedRelay().description}</p>
</Show>
<RelayDetailCard relay={r()} />
</div>
)}
</Show>
+14 -1
View File
@@ -1,6 +1,6 @@
import { useNavigate, useParams } from "@solidjs/router"
import { Show, createEffect, createResource, createSignal } from "solid-js"
import { getTenantRelay, updateTenantRelay } from "../../lib/api"
import { getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api"
import RelayForm from "../../components/RelayForm"
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
import { slugify } from "../../lib/slugify"
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
import ResourceState from "../../components/ResourceState"
import useMinLoading from "../../components/useMinLoading"
const DEFAULT_CONFIG: RelayConfig = {
policy: { public_join: false, strip_signatures: false },
groups: { enabled: false, auto_join: false },
management: { enabled: false },
blossom: { enabled: false },
push: { enabled: false },
}
export default function RelayEdit() {
const navigate = useNavigate()
const params = useParams()
@@ -21,6 +29,7 @@ export default function RelayEdit() {
const [icon, setIcon] = createSignal("")
const [description, setDescription] = createSignal("")
const [plan, setPlan] = createSignal<RelayPlanId>("free")
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -32,6 +41,7 @@ export default function RelayEdit() {
setIcon(data.icon)
setDescription(data.description)
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
setConfig(data.config ?? DEFAULT_CONFIG)
})
async function handleSubmit(e: Event) {
@@ -45,6 +55,7 @@ export default function RelayEdit() {
icon: icon().trim(),
description: description().trim(),
plan: plan(),
config: config(),
})
navigate(`/relays/${relayId()}`)
} catch (e) {
@@ -79,6 +90,8 @@ export default function RelayEdit() {
plan={plan()}
setPlan={setPlan}
plans={RELAY_PLAN_IDS}
config={config()}
setConfig={setConfig}
onSubmit={handleSubmit}
submitting={submitting()}
error={error()}