diff --git a/backend/migrations/0004_unique_relay_subdomain.sql b/backend/migrations/0004_unique_relay_subdomain.sql new file mode 100644 index 0000000..d7a3f99 --- /dev/null +++ b/backend/migrations/0004_unique_relay_subdomain.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS relays_subdomain_unique +ON relays (subdomain); diff --git a/backend/src/api.rs b/backend/src/api.rs index 8c8e370..7ff16f0 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -76,6 +76,19 @@ fn not_found() -> Response { (StatusCode::NOT_FOUND, Json(ApiError { error: "not found".into() })).into_response() } +fn is_unique_subdomain_violation(err: &anyhow::Error) -> bool { + let Some(sqlx_err) = err.downcast_ref::() else { + return false; + }; + + let sqlx::Error::Database(db_err) = sqlx_err else { + return false; + }; + + db_err.message().contains("relays.subdomain") + || db_err.message().contains("relays_subdomain_unique") +} + fn extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Result { let auth_header = headers .get(axum::http::header::AUTHORIZATION) @@ -189,7 +202,11 @@ async fn create_tenant_relay( status: "pending".to_string(), }; - if let Err(_) = state.repo.create_relay(&relay).await { + if let Err(err) = state.repo.create_relay(&relay).await { + if is_unique_subdomain_violation(&err) { + return (StatusCode::CONFLICT, Json(ApiError { error: "subdomain already exists".into() })).into_response(); + } + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create relay".into() })).into_response(); } @@ -261,7 +278,11 @@ async fn update_tenant_relay( status: existing.status, }; - if let Err(_) = state.repo.update_relay(&updated).await { + if let Err(err) = state.repo.update_relay(&updated).await { + if is_unique_subdomain_violation(&err) { + return (StatusCode::CONFLICT, Json(ApiError { error: "subdomain already exists".into() })).into_response(); + } + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response(); } @@ -535,7 +556,11 @@ async fn admin_update_relay( status: existing.status, }; - if let Err(_) = state.repo.update_relay(&updated).await { + if let Err(err) = state.repo.update_relay(&updated).await { + if is_unique_subdomain_violation(&err) { + return (StatusCode::CONFLICT, Json(ApiError { error: "subdomain already exists".into() })).into_response(); + } + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response(); } diff --git a/backend/src/provisioning.rs b/backend/src/provisioning.rs index b535ad5..f15017c 100644 --- a/backend/src/provisioning.rs +++ b/backend/src/provisioning.rs @@ -54,7 +54,7 @@ impl Provisioner { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - return Err(anyhow!("zooid provisioning failed: {} {}", status, body)); + return Err(anyhow!("zooid provisioning failed for {}: {} {}", url, status, body)); } Ok(()) diff --git a/frontend/README.md b/frontend/README.md index 908fe50..a0c46fc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -80,7 +80,6 @@ Super admin dashboard: ## Todos - [ ] Marketing page (`/`) with value props, features, and CTA -- [ ] Login page (`/login`) via nonboard - [ ] Tenant dashboard auth via NIP-98 - [ ] Relays list (`/relays`) with search/filter and add relay CTA - [ ] Relay detail (`/relays/:id`) with edit + deactivate actions diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..c19132f --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,120 @@ +import { accounts, API_URL } from "./nostr" + +type NostrTag = string[] + +type UnsignedEvent = { + kind: number + content: string + created_at: number + tags: NostrTag[] +} + +type SignedEvent = UnsignedEvent & { + id: string + pubkey: string + sig: string +} + +type EventSigner = { + signEvent(event: UnsignedEvent): Promise +} + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = "ApiError" + this.status = status + } +} + +function getActiveSigner(): EventSigner { + const account = accounts.getActive() as { signer?: EventSigner } | undefined + if (!account?.signer) throw new Error("Not logged in") + return account.signer +} + +async function createNip98Header(url: string, method: string): Promise { + const signer = getActiveSigner() + const event = await signer.signEvent({ + kind: 27235, + content: "", + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["u", url], + ["method", method.toUpperCase()], + ], + }) + + const encoded = btoa(JSON.stringify(event)) + return `Nostr ${encoded}` +} + +async function request(path: string, init?: RequestInit): Promise { + const method = (init?.method ?? "GET").toUpperCase() + const url = new URL(path, API_URL).toString() + const auth = await createNip98Header(url, method) + + const response = await fetch(url, { + ...init, + method, + headers: { + ...(init?.headers ?? {}), + Authorization: auth, + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + let message = `Request failed (${response.status})` + try { + const body = await response.json() as { error?: string } + if (body.error) message = body.error + } catch { + // ignored + } + throw new ApiError(message, response.status) + } + + if (response.status === 204) { + return undefined as T + } + + return response.json() as Promise +} + +export type Relay = { + id: string + tenant: string + name: string + subdomain: string + schema: string + icon: string + description: string + plan: string + status: string +} + +export function listTenantRelays() { + return request("/tenant/relays") +} + +export type CreateRelayInput = { + name: string + subdomain: string + icon: string + description: string + plan: string +} + +export function createTenantRelay(input: CreateRelayInput) { + return request("/tenant/relays", { + method: "POST", + body: JSON.stringify(input), + }) +} + +export function getTenantRelay(id: string) { + return request(`/tenant/relays/${id}`) +} diff --git a/frontend/src/lib/nostr.ts b/frontend/src/lib/nostr.ts index c42fec4..c137f68 100644 --- a/frontend/src/lib/nostr.ts +++ b/frontend/src/lib/nostr.ts @@ -29,8 +29,8 @@ export function restoreAccounts() { try { const saved = JSON.parse(raw) as SerializedAccount[] accounts.fromJSON(saved, true) - } catch (error) { - console.warn("Failed to restore accounts", error) + } catch { + // ignore corrupted local state } } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 0fd7fbe..0288168 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -68,7 +68,6 @@ export default function Login() { try { await completeLogin(await ExtensionAccount.fromExtension()) } catch (e) { - console.error("NIP-07 login failed", e) setError(e instanceof Error ? e.message : "Failed to login with extension") } finally { setLoading(false) diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index 7b8a3a9..4797624 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -1,14 +1,37 @@ import { useParams, A } from "@solidjs/router" +import { createResource, Show } from "solid-js" +import { getTenantRelay } from "../../lib/api" export default function RelayDetail() { const params = useParams() + const [relay] = createResource(() => params.id, getTenantRelay) return (
-

Relay {params.id}

+ + +

Loading relay...

+
+ + +

Failed to load relay.

+
+ + + {(loadedRelay) => ( +
+

{loadedRelay().name}

+

https://{loadedRelay().subdomain}.spaces.coracle.social

+ +

{loadedRelay().description}

+
+
+ )} +
+
-

No relays yet. Create one to get started.

+ + +

Loading relays...

+
+ + +

Failed to load relays.

+
+ + 0} fallback={

No relays yet. Create one to get started.

}> + +
) } diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index 764b8f1..09a1ad8 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -1,5 +1,6 @@ import { createSignal } from "solid-js" import { useNavigate } from "@solidjs/router" +import { createTenantRelay, listTenantRelays } from "../../lib/api" const PLANS = [ { id: "free", label: "Free", price: 0, members: "Up to 10", blossom: false, livekit: false }, @@ -26,22 +27,54 @@ export default function RelayNew() { const [icon, setIcon] = createSignal("") const [description, setDescription] = createSignal("") const [plan, setPlan] = createSignal("free") + const [submitting, setSubmitting] = createSignal(false) + const [error, setError] = createSignal("") function handleNameInput(value: string) { setName(value) setSubdomain(slugify(value)) } - function handleSubmit(e: Event) { + async function handleSubmit(e: Event) { e.preventDefault() - // TODO: submit to backend, handle invoice flow - navigate("/relays") + + setError("") + setSubmitting(true) + + try { + const normalizedSubdomain = slugify(subdomain()) + const existingRelays = await listTenantRelays() + const hasDuplicate = existingRelays.some(relay => relay.subdomain.toLowerCase() === normalizedSubdomain.toLowerCase()) + + if (hasDuplicate) { + throw new Error("You already have a relay with this subdomain") + } + + const relay = await createTenantRelay({ + name: name().trim(), + subdomain: normalizedSubdomain, + icon: icon().trim(), + description: description().trim(), + plan: plan(), + }) + navigate(`/relays/${relay.id}`) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to create relay") + } finally { + setSubmitting(false) + } } return (

New Relay

+ {error() && ( +
+ {error()} +
+ )} +
- Create Relay + {submitting() ? "Creating..." : "Create Relay"}
diff --git a/justfile b/justfile index e0593f3..138d59e 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ dev: #!/usr/bin/env sh trap 'kill 0' EXIT - cd backend && cargo run & + cd backend && RUST_LOG=backend=info cargo run & cd frontend && bun dev & wait