diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..29731583d3 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -43,6 +43,8 @@ pub mod reaction; pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. pub mod replica_fence; +/// Space self-service persistence. +pub mod space; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. @@ -943,6 +945,43 @@ impl Db { )) } + /// Atomically create a Space with the signer as sole owner and a #general + /// stream channel. Enforces `MAX_COMMUNITIES_PER_OWNER` and host collision + /// semantics via the same advisory-lock pattern as + /// [`create_community_with_owner`]. + pub async fn create_space( + &self, + slug: &str, + space_name: &str, + visibility: &str, + owner_pubkey: &str, + ) -> Result { + space::create_space(&self.pool, slug, space_name, visibility, owner_pubkey).await + } + + /// List active public Spaces, plus any active Spaces where `member_pubkey` + /// is a relay member. + pub async fn list_spaces( + &self, + member_pubkey: Option<&str>, + ) -> Result> { + space::list_spaces(&self.pool, member_pubkey).await + } + + /// Look up a Space by its slug. + pub async fn lookup_space_by_slug( + &self, + slug: &str, + ) -> Result> { + space::lookup_space_by_slug(&self.pool, slug).await + } + + /// Idempotently join a public Space: add the pubkey as a relay member + /// of the target community. + pub async fn join_space(&self, community_id: CommunityId, pubkey: &str) -> Result { + space::join_space(&self.pool, community_id, pubkey).await + } + /// Idempotently archives a community when the asserted pubkey is its current owner. pub async fn archive_community_owned_by( &self, diff --git a/crates/buzz-db/src/space.rs b/crates/buzz-db/src/space.rs new file mode 100644 index 0000000000..d9040665b8 --- /dev/null +++ b/crates/buzz-db/src/space.rs @@ -0,0 +1,706 @@ +//! Space self-service persistence — create, list, and join Spaces. +//! +//! A Space is a community with a human-readable `space_name` and +//! `space_visibility` (public/private). The host is derived from the slug: +//! `.relay.nuri.com`. Creating a Space atomically bootstraps the +//! signer as owner, creates a #general stream channel, and adds the owner +//! as a channel member. Joining a public Space adds the signer as a relay +//! member of the target community. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::error::{DbError, Result}; +use crate::relay_members::{self, MAX_COMMUNITIES_PER_OWNER}; +use crate::CommunityId; + +/// A Space as returned by list queries. +#[derive(Debug, Clone)] +pub struct SpaceRecord { + /// Stable server-resolved community id. + pub community_id: CommunityId, + /// Human-readable Space display name. + pub space_name: String, + /// Visibility: "public" or "private". + pub space_visibility: String, + /// Full host derived from the slug. + pub host: String, + /// When the space was created. + pub created_at: DateTime, + /// The caller's role in this space, if they are a relay member. + pub role: Option, + /// Server-confirmed bootstrap #general channel. + pub general_channel_id: Uuid, +} + +/// Result of atomically creating a Space. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateSpaceResult { + /// The Space was created. + Created { + /// Community id of the new Space. + community_id: CommunityId, + /// The full host. + host: String, + /// The #general channel id. + general_channel_id: Uuid, + }, + /// The slug (and therefore host) is already taken. + SlugExists, + /// The owner already owns the maximum number of communities. + LimitReached, +} + +/// The public suffix appended to the slug to form the community host. +pub const SPACE_HOST_SUFFIX: &str = ".relay.nuri.com"; + +/// Validate that a slug is a lowercase DNS label: 1-63 chars, alphanumeric +/// plus hyphens, no leading/trailing hyphens. +pub fn validate_space_slug(slug: &str) -> Result<()> { + if slug.is_empty() || slug.len() > 63 { + return Err(DbError::InvalidData( + "space slug must be 1-63 characters".into(), + )); + } + let bytes = slug.as_bytes(); + if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { + return Err(DbError::InvalidData( + "space slug must start with a lowercase letter or digit".into(), + )); + } + if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() { + return Err(DbError::InvalidData( + "space slug must end with a lowercase letter or digit".into(), + )); + } + for &b in bytes { + if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'-' { + return Err(DbError::InvalidData(format!( + "space slug contains invalid character '{}'", + b as char + ))); + } + } + Ok(()) +} + +/// Build the full community host from a slug. +pub fn space_host_from_slug(slug: &str) -> String { + format!("{slug}{SPACE_HOST_SUFFIX}") +} + +/// Atomically create a Space: community row with space_name + visibility, +/// owner relay membership, and a #general stream channel with owner membership. +/// +/// Enforces `MAX_COMMUNITIES_PER_OWNER` and host collision semantics via +/// the same advisory-lock pattern as `create_community_with_owner`. +pub async fn create_space( + pool: &PgPool, + slug: &str, + space_name: &str, + visibility: &str, + owner_pubkey: &str, +) -> Result { + validate_space_slug(slug)?; + let space_name = space_name.trim(); + if space_name.is_empty() || space_name.chars().count() > 80 { + return Err(DbError::InvalidData( + "space name must be 1-80 characters".into(), + )); + } + if visibility != "public" && visibility != "private" { + return Err(DbError::InvalidData( + "space visibility must be public or private".into(), + )); + } + if owner_pubkey.len() != 64 || !owner_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(DbError::InvalidData( + "owner pubkey must be 64 hexadecimal characters".into(), + )); + } + let host = space_host_from_slug(slug); + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + + let mut tx = pool.begin().await?; + + // Serialize on the owner pubkey so concurrent creates to the same + // owner cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx) + .await?; + + // Try to insert the community row with space fields. + let row = sqlx::query( + r#" + INSERT INTO communities (host, space_name, space_visibility) + VALUES ($1, $2, $3) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING id, host + "#, + ) + .bind(&host) + .bind(space_name) + .bind(visibility) + .fetch_optional(&mut *tx) + .await?; + + let (community_id, existing_general_channel_id) = if let Some(row) = row { + let id: Uuid = row.try_get("id")?; + + // Enforce the per-owner community limit. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&owner_pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= MAX_COMMUNITIES_PER_OWNER { + tx.rollback().await?; + return Ok(CreateSpaceResult::LimitReached); + } + + // Insert owner relay membership. + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'owner', NULL)", + ) + .bind(id) + .bind(&owner_pubkey) + .execute(&mut *tx) + .await?; + + (CommunityId::from_uuid(id), None) + } else { + // Identical retries by the same owner recover the already-created + // Space. A different owner or different metadata remains a collision. + let existing = sqlx::query( + r#" + SELECT c.id, ch.id AS general_channel_id + FROM communities c + JOIN relay_members rm + ON rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + JOIN channels ch + ON ch.community_id = c.id + AND ch.name = 'general' + AND ch.channel_type = 'stream' + AND ch.archived_at IS NULL + WHERE lower(c.host) = lower($1) + AND c.space_name = $3 + AND c.space_visibility = $4 + AND c.archived_at IS NULL + "#, + ) + .bind(&host) + .bind(&owner_pubkey) + .bind(space_name) + .bind(visibility) + .fetch_optional(&mut *tx) + .await?; + let Some(existing) = existing else { + tx.rollback().await?; + return Ok(CreateSpaceResult::SlugExists); + }; + ( + CommunityId::from_uuid(existing.try_get("id")?), + Some(existing.try_get("general_channel_id")?), + ) + }; + + if let Some(general_channel_id) = existing_general_channel_id { + tx.commit().await?; + return Ok(CreateSpaceResult::Created { + community_id, + host, + general_channel_id, + }); + } + + // Create the #general stream channel with owner membership. + let channel_id = Uuid::new_v4(); + let owner_bytes = hex::decode(&owner_pubkey) + .map_err(|_| DbError::InvalidData("invalid owner pubkey hex".into()))?; + + sqlx::query( + r#" + INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) + VALUES ($1, $2, 'general', 'stream'::channel_type, 'open'::channel_visibility, $3) + "#, + ) + .bind(channel_id) + .bind(community_id.as_uuid()) + .bind(&owner_bytes) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'owner', $4) + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(&owner_bytes) + .bind(&owner_bytes) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(CreateSpaceResult::Created { + community_id, + host, + general_channel_id: channel_id, + }) +} + +/// List active public Spaces, plus any active Spaces where `member_pubkey` +/// is a relay member (for the caller's own private Spaces). +pub async fn list_spaces(pool: &PgPool, member_pubkey: Option<&str>) -> Result> { + let rows = sqlx::query( + r#" + SELECT c.id, c.space_name, c.space_visibility, c.host, c.created_at, + rm.role, ch.id AS general_channel_id + FROM communities c + JOIN channels ch + ON ch.community_id = c.id + AND ch.name = 'general' + AND ch.channel_type = 'stream' + AND ch.archived_at IS NULL + LEFT JOIN relay_members rm + ON rm.community_id = c.id + AND rm.pubkey = $1 + WHERE c.archived_at IS NULL + AND c.space_name IS NOT NULL + AND ( + c.space_visibility = 'public' + OR ($1 IS NOT NULL AND rm.pubkey IS NOT NULL) + ) + ORDER BY c.created_at DESC + "#, + ) + .bind(member_pubkey) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|r| { + Ok(SpaceRecord { + community_id: CommunityId::from_uuid(r.try_get("id")?), + space_name: r.try_get("space_name")?, + space_visibility: r.try_get("space_visibility")?, + host: r.try_get("host")?, + created_at: r.try_get("created_at")?, + role: r.try_get("role")?, + general_channel_id: r.try_get("general_channel_id")?, + }) + }) + .collect() +} + +/// Look up a Space by its slug. Returns the community id and visibility +/// if the space exists and is active. +pub async fn lookup_space_by_slug( + pool: &PgPool, + slug: &str, +) -> Result> { + validate_space_slug(slug)?; + let host = space_host_from_slug(slug); + let row = sqlx::query( + r#" + SELECT c.id, c.space_visibility, c.host, c.space_name, + ch.id AS general_channel_id + FROM communities c + JOIN channels ch + ON ch.community_id = c.id + AND ch.name = 'general' + AND ch.channel_type = 'stream' + AND ch.archived_at IS NULL + WHERE lower(c.host) = lower($1) + AND c.archived_at IS NULL + AND c.space_name IS NOT NULL + "#, + ) + .bind(&host) + .fetch_optional(pool) + .await?; + + row.map(|r| { + Ok(( + CommunityId::from_uuid(r.try_get("id")?), + r.try_get("space_visibility")?, + r.try_get("host")?, + r.try_get("space_name")?, + r.try_get("general_channel_id")?, + )) + }) + .transpose() +} + +/// Idempotently join a public Space: add the pubkey as a relay member +/// of the target community. Returns `true` if the membership was newly +/// inserted. +pub async fn join_space(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(DbError::InvalidData( + "member pubkey must be 64 hexadecimal characters".into(), + )); + } + let inserted = sqlx::query_scalar::<_, String>( + r#" + INSERT INTO relay_members (community_id, pubkey, role, added_by) + SELECT c.id, $2, 'member', NULL + FROM communities c + WHERE c.id = $1 + AND c.archived_at IS NULL + AND c.space_name IS NOT NULL + AND c.space_visibility = 'public' + ON CONFLICT (community_id, pubkey) DO NOTHING + RETURNING pubkey + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey.to_ascii_lowercase()) + .fetch_optional(pool) + .await?; + Ok(inserted.is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_valid_lowercase_dns_labels() { + assert!(validate_space_slug("my-space").is_ok()); + assert!(validate_space_slug("a").is_ok()); + assert!(validate_space_slug("abc123").is_ok()); + assert!(validate_space_slug("test-space-42").is_ok()); + } + + #[test] + fn slug_rejects_invalid() { + assert!(validate_space_slug("").is_err()); + assert!(validate_space_slug("-bad").is_err()); + assert!(validate_space_slug("bad-").is_err()); + assert!(validate_space_slug("UPPERCASE").is_err()); + assert!(validate_space_slug("has_underscore").is_err()); + assert!(validate_space_slug("has space").is_err()); + assert!(validate_space_slug(&"a".repeat(64)).is_err()); + } + + #[test] + fn host_from_slug_appends_suffix() { + assert_eq!(space_host_from_slug("my-space"), "my-space.relay.nuri.com"); + } + + #[test] + fn suffix_is_exact() { + assert_eq!(SPACE_HOST_SUFFIX, ".relay.nuri.com"); + } + + // ── DB integration tests (require Postgres) ── + + mod db_tests { + use super::*; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + fn unique_slug() -> String { + format!("test-space-{}", Uuid::new_v4().simple()) + } + + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_space_returns_created_with_general_channel() { + let pool = setup_pool().await; + let slug = unique_slug(); + let owner = test_pubkey(); + + let result = create_space(&pool, &slug, "Test Space", "public", &owner) + .await + .expect("create space"); + let retry = create_space(&pool, &slug, "Test Space", "public", &owner) + .await + .expect("retry create space"); + assert_eq!(retry, result, "identical create must be idempotent"); + + let CreateSpaceResult::Created { + community_id, + host, + general_channel_id, + } = result + else { + panic!("expected Created, got {:?}", result); + }; + + assert_eq!(host, space_host_from_slug(&slug)); + + // Owner is a relay member with owner role. + let role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community_id.as_uuid()) + .bind(&owner) + .fetch_optional(&pool) + .await + .expect("owner role query"); + assert_eq!(role.as_deref(), Some("owner")); + + // #general channel exists. + let channel_name: Option = + sqlx::query_scalar("SELECT name FROM channels WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(general_channel_id) + .fetch_optional(&pool) + .await + .expect("channel query"); + assert_eq!(channel_name.as_deref(), Some("general")); + + // Owner is a channel member with owner role. + let owner_bytes = hex::decode(&owner).expect("decode owner pubkey"); + let channel_role: Option = sqlx::query_scalar( + "SELECT role::text FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id.as_uuid()) + .bind(general_channel_id) + .bind(&owner_bytes) + .fetch_optional(&pool) + .await + .expect("channel member query"); + assert_eq!(channel_role.as_deref(), Some("owner")); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_space_rejects_duplicate_slug() { + let pool = setup_pool().await; + let slug = unique_slug(); + let owner_a = test_pubkey(); + let owner_b = test_pubkey(); + + let first = create_space(&pool, &slug, "First", "public", &owner_a) + .await + .expect("first create"); + assert!(matches!(first, CreateSpaceResult::Created { .. })); + + let second = create_space(&pool, &slug, "Second", "public", &owner_b) + .await + .expect("second create"); + assert_eq!(second, CreateSpaceResult::SlugExists); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_slug_collision_creates_exactly_one_space() { + let pool = setup_pool().await; + let slug = unique_slug(); + let owner_a = test_pubkey(); + let owner_b = test_pubkey(); + + let (first, second) = tokio::join!( + create_space(&pool, &slug, "Concurrent Space", "public", &owner_a), + create_space(&pool, &slug, "Concurrent Space", "public", &owner_b), + ); + let results = [first.expect("first create"), second.expect("second create")]; + assert_eq!( + results + .iter() + .filter(|result| matches!(result, CreateSpaceResult::Created { .. })) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, CreateSpaceResult::SlugExists)) + .count(), + 1 + ); + + let owner_count: i64 = sqlx::query_scalar( + r#" + SELECT count(*) + FROM relay_members rm + JOIN communities c ON c.id = rm.community_id + WHERE lower(c.host) = lower($1) AND rm.role = 'owner' + "#, + ) + .bind(space_host_from_slug(&slug)) + .fetch_one(&pool) + .await + .expect("owner count"); + assert_eq!(owner_count, 1); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_space_enforces_owner_quota() { + let pool = setup_pool().await; + let owner = test_pubkey(); + + // Create MAX_COMMUNITIES_PER_OWNER spaces. + for i in 0..MAX_COMMUNITIES_PER_OWNER { + let slug = format!("quota-{}-{}", i, Uuid::new_v4().simple()); + let result = create_space(&pool, &slug, "Quota Test", "public", &owner) + .await + .expect("create space within quota"); + assert!( + matches!(result, CreateSpaceResult::Created { .. }), + "space {i} should be created" + ); + } + + // The next one should hit the limit. + let slug = format!("quota-over-{}", Uuid::new_v4().simple()); + let result = create_space(&pool, &slug, "Over Quota", "public", &owner) + .await + .expect("create space over quota"); + assert_eq!(result, CreateSpaceResult::LimitReached); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_spaces_returns_public_and_own_private() { + let pool = setup_pool().await; + let owner_a = test_pubkey(); + let owner_b = test_pubkey(); + let slug_public = unique_slug(); + let slug_private = unique_slug(); + + // Create a public space owned by A. + let result = create_space(&pool, &slug_public, "Public Space", "public", &owner_a) + .await + .expect("create public space"); + let CreateSpaceResult::Created { + community_id: public_id, + .. + } = result + else { + panic!("expected Created"); + }; + + // Create a private space owned by B. + let result = create_space(&pool, &slug_private, "Private Space", "private", &owner_b) + .await + .expect("create private space"); + let CreateSpaceResult::Created { + community_id: private_id, + .. + } = result + else { + panic!("expected Created"); + }; + + // B can see the public space and their own private space. + let b_spaces = list_spaces(&pool, Some(&owner_b)) + .await + .expect("list spaces for B"); + let b_ids: Vec<_> = b_spaces.iter().map(|s| s.community_id).collect(); + assert!(b_ids.contains(&public_id), "B should see the public space"); + assert!( + b_ids.contains(&private_id), + "B should see their own private space" + ); + + // An outsider (not a member of either) sees only the public space. + let outsider = test_pubkey(); + let outsider_spaces = list_spaces(&pool, Some(&outsider)) + .await + .expect("list spaces for outsider"); + let outsider_ids: Vec<_> = outsider_spaces.iter().map(|s| s.community_id).collect(); + assert!( + outsider_ids.contains(&public_id), + "outsider should see the public space" + ); + assert!( + !outsider_ids.contains(&private_id), + "outsider should NOT see the private space" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn join_space_is_idempotent() { + let pool = setup_pool().await; + let slug = unique_slug(); + let owner = test_pubkey(); + let joiner = test_pubkey(); + + let result = create_space(&pool, &slug, "Join Test", "public", &owner) + .await + .expect("create space"); + let CreateSpaceResult::Created { community_id, .. } = result else { + panic!("expected Created"); + }; + + // First join inserts. + let first = join_space(&pool, community_id, &joiner) + .await + .expect("first join"); + assert!(first, "first join should insert"); + + // Second join is idempotent. + let second = join_space(&pool, community_id, &joiner) + .await + .expect("second join"); + assert!(!second, "second join should be idempotent"); + + // Joiner is a relay member. + let is_member = relay_members::is_relay_member(&pool, community_id, &joiner) + .await + .expect("is member check"); + assert!(is_member); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_space_by_slug_finds_active_space() { + let pool = setup_pool().await; + let slug = unique_slug(); + let owner = test_pubkey(); + + create_space(&pool, &slug, "Lookup Test", "public", &owner) + .await + .expect("create space"); + + let found = lookup_space_by_slug(&pool, &slug).await.expect("lookup"); + assert!(found.is_some()); + let (_, visibility, host, name, general_channel_id) = found.unwrap(); + assert_eq!(visibility, "public"); + assert_eq!(host, space_host_from_slug(&slug)); + assert_eq!(name, "Lookup Test"); + assert_ne!(general_channel_id, Uuid::nil()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_space_by_slug_returns_none_for_unknown() { + let pool = setup_pool().await; + let found = lookup_space_by_slug(&pool, "nonexistent-99999") + .await + .expect("lookup"); + assert!(found.is_none()); + } + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 186cda0da5..40f7d6fe56 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -10,6 +10,7 @@ pub mod mesh_demo; pub mod nip05; pub mod nuri; pub mod operator; +pub mod spaces; // Re-export imeta helpers used by ingest pipeline. pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; diff --git a/crates/buzz-relay/src/api/spaces.rs b/crates/buzz-relay/src/api/spaces.rs new file mode 100644 index 0000000000..ea45aff11a --- /dev/null +++ b/crates/buzz-relay/src/api/spaces.rs @@ -0,0 +1,321 @@ +//! Nuri Space self-service HTTP API. +//! +//! All routes are NIP-98 signed and available only on the deployment hub host: +//! +//! - `GET /api/nuri/spaces` lists public Spaces plus the caller's memberships. +//! - `POST /api/nuri/spaces` creates a Space owned by the caller. +//! - `POST /api/nuri/spaces/join` joins a public Space. +//! +//! Private Spaces continue to use the existing tenant-bound invite claim API. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use buzz_core::{normalize_host, TenantContext}; +use nostr::PublicKey; +use serde::Deserialize; +use serde_json::Value; + +use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Body for `POST /api/nuri/spaces`. +#[derive(Debug, Deserialize)] +pub struct CreateSpaceRequest { + /// Human-readable display name. + pub name: String, + /// Lowercase DNS label used in `.relay.nuri.com`. + pub slug: String, + /// Either `public` or `private`. + pub visibility: String, +} + +/// Body for `POST /api/nuri/spaces/join`. +#[derive(Debug, Deserialize)] +pub struct JoinSpaceRequest { + /// Public Space slug to join. + pub slug: String, +} + +fn hub_host_matches(config_relay_url: &str, raw_host: &str) -> bool { + let expected = buzz_core::tenant::relay_url_authority(config_relay_url); + !expected.is_empty() && normalize_host(raw_host) == expected +} + +async fn authenticate_hub( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + body: Option<&[u8]>, +) -> Result<(TenantContext, PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !hub_host_matches(&state.config.relay_url, raw_host) { + return Err(api_error( + StatusCode::FORBIDDEN, + "space endpoints are only available on the hub host", + )); + } + + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = + bridge::verify_bridge_auth_with_options(headers, method, &url, body, true, body.is_some())?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + Ok((tenant, pubkey)) +} + +async fn require_hub_member( + state: &AppState, + tenant: &TenantContext, + signer: &PublicKey, +) -> Result)> { + let signer_hex = signer.to_hex(); + let is_member = state + .db + .is_relay_member(tenant.community(), &signer_hex) + .await + .map_err(|error| internal_error(&format!("hub membership check: {error}")))?; + if !is_member { + return Err(api_error( + StatusCode::FORBIDDEN, + "you must be a Nuri member of the hub", + )); + } + Ok(signer_hex) +} + +fn validate_slug(slug: &str) -> Result<(), (StatusCode, Json)> { + buzz_db::space::validate_space_slug(slug) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid Space slug")) +} + +fn slug_from_host(host: &str) -> Result<&str, (StatusCode, Json)> { + host.strip_suffix(buzz_db::space::SPACE_HOST_SUFFIX) + .ok_or_else(|| internal_error("stored Space host has an invalid suffix")) +} + +/// `GET /api/nuri/spaces` — list public Spaces plus the caller's memberships. +pub async fn list_spaces( + State(state): State>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let (tenant, signer) = + authenticate_hub(&state, &headers, "GET", "/api/nuri/spaces", None).await?; + let signer_hex = require_hub_member(&state, &tenant, &signer).await?; + let spaces = state + .db + .list_spaces(Some(&signer_hex)) + .await + .map_err(|error| internal_error(&format!("list Spaces: {error}")))?; + + let mut result = Vec::with_capacity(spaces.len()); + for space in spaces { + let slug = slug_from_host(&space.host)?; + let is_member = space.role.is_some(); + result.push(serde_json::json!({ + "community_id": space.community_id.to_string(), + "name": space.space_name, + "slug": slug, + "host": space.host, + "relay_url": format!("wss://{}", space.host), + "visibility": space.space_visibility, + "role": space.role, + "is_member": is_member, + "general_channel_id": space.general_channel_id.to_string(), + "created_at": space.created_at.to_rfc3339(), + })); + } + + Ok(Json(serde_json::json!({ "spaces": result }))) +} + +/// `POST /api/nuri/spaces` — atomically create a Space owned by the signer. +pub async fn create_space( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, signer) = + authenticate_hub(&state, &headers, "POST", "/api/nuri/spaces", Some(&body)).await?; + let signer_hex = require_hub_member(&state, &tenant, &signer).await?; + let request: CreateSpaceRequest = serde_json::from_slice(&body).map_err(|error| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid create-Space JSON: {error}"), + ) + })?; + validate_slug(&request.slug)?; + + let name = request.name.trim(); + if name.is_empty() || name.chars().count() > 80 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "Space name must be 1-80 characters", + )); + } + if request.visibility != "public" && request.visibility != "private" { + return Err(api_error( + StatusCode::BAD_REQUEST, + "visibility must be public or private", + )); + } + + match state + .db + .create_space(&request.slug, name, &request.visibility, &signer_hex) + .await + .map_err(|error| internal_error(&format!("create Space: {error}")))? + { + buzz_db::space::CreateSpaceResult::Created { + community_id, + host, + general_channel_id, + } => { + let space_tenant = TenantContext::resolved(community_id, &host); + if let Err(error) = publish_nip43_member_added(&space_tenant, &state, &signer_hex).await + { + tracing::warn!(%error, "failed to publish Space owner delta"); + } + if let Err(error) = publish_nip43_membership_list(&space_tenant, &state).await { + tracing::warn!(%error, "failed to publish Space membership list"); + } + + Ok(Json(serde_json::json!({ + "community_id": community_id.to_string(), + "name": name, + "slug": request.slug, + "host": host, + "relay_url": format!("wss://{host}"), + "visibility": request.visibility, + "role": "owner", + "is_member": true, + "general_channel_id": general_channel_id.to_string(), + }))) + } + buzz_db::space::CreateSpaceResult::SlugExists => Err(api_error( + StatusCode::CONFLICT, + "a Space with this slug already exists", + )), + buzz_db::space::CreateSpaceResult::LimitReached => Err(api_error( + StatusCode::CONFLICT, + "you have reached the maximum number of Spaces you can own", + )), + } +} + +/// `POST /api/nuri/spaces/join` — idempotently join a public Space. +pub async fn join_space( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, signer) = authenticate_hub( + &state, + &headers, + "POST", + "/api/nuri/spaces/join", + Some(&body), + ) + .await?; + let signer_hex = require_hub_member(&state, &tenant, &signer).await?; + let request: JoinSpaceRequest = serde_json::from_slice(&body).map_err(|error| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid join-Space JSON: {error}"), + ) + })?; + validate_slug(&request.slug)?; + + let (target_community, visibility, target_host, name, general_channel_id) = state + .db + .lookup_space_by_slug(&request.slug) + .await + .map_err(|error| internal_error(&format!("lookup Space: {error}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Space not found"))?; + if visibility != "public" { + return Err(api_error( + StatusCode::FORBIDDEN, + "this Space is private; use an invite to join", + )); + } + + let was_inserted = state + .db + .join_space(target_community, &signer_hex) + .await + .map_err(|error| internal_error(&format!("join Space: {error}")))?; + let role = state + .db + .get_relay_member(target_community, &signer_hex) + .await + .map_err(|error| internal_error(&format!("joined role lookup: {error}")))? + .map(|member| member.role) + .ok_or_else(|| api_error(StatusCode::CONFLICT, "Space is no longer public"))?; + + if was_inserted { + let space_tenant = TenantContext::resolved(target_community, &target_host); + if let Err(error) = publish_nip43_member_added(&space_tenant, &state, &signer_hex).await { + tracing::warn!(%error, "failed to publish Space member delta"); + } + if let Err(error) = publish_nip43_membership_list(&space_tenant, &state).await { + tracing::warn!(%error, "failed to publish Space membership list"); + } + } + + Ok(Json(serde_json::json!({ + "community_id": target_community.to_string(), + "name": name, + "slug": request.slug, + "host": target_host, + "relay_url": format!("wss://{target_host}"), + "visibility": visibility, + "role": role, + "is_member": true, + "general_channel_id": general_channel_id.to_string(), + "status": if was_inserted { "joined" } else { "already_member" }, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hub_host_comparison_uses_canonical_host_rules() { + assert!(hub_host_matches( + "wss://relay.nuri.com", + "Relay.Nuri.Com:443" + )); + assert!(!hub_host_matches( + "wss://relay.nuri.com", + "space.relay.nuri.com" + )); + } + + #[test] + fn slug_validation_and_suffix_are_exact() { + assert!(validate_slug("nuri-builders").is_ok()); + assert!(validate_slug("Nuri-Builders").is_err()); + assert_eq!( + buzz_db::space::space_host_from_slug("nuri-builders"), + "nuri-builders.relay.nuri.com" + ); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 24dcf4d4bf..6f1c99f61e 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -110,6 +110,12 @@ pub fn build_router(state: Arc) -> Router { ) .route("/api/invites/claim", post(api::invites::claim_invite)) .route("/api/nuri/register", post(api::nuri::register)) + // Space self-service: create, list, and join Spaces. + .route( + "/api/nuri/spaces", + get(api::spaces::list_spaces).post(api::spaces::create_space), + ) + .route("/api/nuri/spaces/join", post(api::spaces::join_space)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) diff --git a/migrations/0025_space_self_service.sql b/migrations/0025_space_self_service.sql new file mode 100644 index 0000000000..18edba560a --- /dev/null +++ b/migrations/0025_space_self_service.sql @@ -0,0 +1,32 @@ +-- Add human-readable Space name and visibility to communities. +-- Spaces are communities with a user-facing name and public/private visibility. +-- Default is 'private' so existing production communities remain control-plane only. +-- The unique Space slug remains encoded in communities.host. + +ALTER TABLE communities + ADD COLUMN space_name VARCHAR(80), + ADD COLUMN space_visibility VARCHAR(16) NOT NULL DEFAULT 'private'; + +-- Constraint: space_visibility must be 'public' or 'private'. +ALTER TABLE communities + ADD CONSTRAINT chk_communities_space_visibility + CHECK (space_visibility IN ('public', 'private')); + +-- Display names are human text. DNS-label validation belongs to the host slug, +-- not this column. +ALTER TABLE communities + ADD CONSTRAINT chk_communities_space_name + CHECK ( + space_name IS NULL + OR length(btrim(space_name)) BETWEEN 1 AND 80 + ); + +-- Display-name lookup without falsely requiring names to be globally unique. +CREATE INDEX idx_communities_space_name + ON communities (lower(space_name)) + WHERE space_name IS NOT NULL; + +-- Index for listing public spaces. +CREATE INDEX idx_communities_space_visibility + ON communities (space_visibility) + WHERE space_visibility = 'public' AND archived_at IS NULL; diff --git a/web/src/app/routes/index.tsx b/web/src/app/routes/index.tsx index 4c01fed30d..1f6c304a3e 100644 --- a/web/src/app/routes/index.tsx +++ b/web/src/app/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ChatPage } from "@/features/chat/ui/ChatPage"; import { NuriWalletGate } from "@/features/nuri-wallet/ui/NuriWalletGate"; +import { SpacesPage } from "@/features/spaces/ui/SpacesPage"; export const Route = createFileRoute("/")({ component: IndexPage, @@ -9,7 +9,7 @@ export const Route = createFileRoute("/")({ function IndexPage() { return ( - + ); } diff --git a/web/src/features/chat/lib/chat-client.ts b/web/src/features/chat/lib/chat-client.ts index 3b9b057646..6515072647 100644 --- a/web/src/features/chat/lib/chat-client.ts +++ b/web/src/features/chat/lib/chat-client.ts @@ -8,6 +8,7 @@ import { getBrowserPublicKey, signNostrEvent } from "@/shared/lib/nostr-signer"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { type ChatChannel, + includeBootstrapGeneralChannel, mergeChatMessages, projectChatChannels, selectAutoJoinChannel, @@ -38,31 +39,44 @@ export type ChatCatalog = { channels: ChatChannel[]; }; -export async function loadChatCatalog(): Promise { - const wsUrl = relayWsUrl(); +export async function loadChatCatalog( + wsUrl = relayWsUrl(), + bootstrapGeneralChannelId?: string, +): Promise { const pubkey = await getBrowserPublicKey(); - let channels = await queryChannelCatalog(wsUrl, pubkey); + let channels = includeBootstrapGeneralChannel( + await queryChannelCatalog(wsUrl, pubkey), + bootstrapGeneralChannelId, + ); const autoJoin = selectAutoJoinChannel(channels); if (autoJoin) { - await joinChatChannel(autoJoin.id); - channels = await queryChannelCatalog(wsUrl, pubkey); + await joinChatChannel(autoJoin.id, wsUrl); + channels = includeBootstrapGeneralChannel( + await queryChannelCatalog(wsUrl, pubkey), + bootstrapGeneralChannelId, + true, + ); } return { pubkey, channels }; } -export async function joinChatChannel(channelId: string): Promise { +export async function joinChatChannel( + channelId: string, + wsUrl = relayWsUrl(), +): Promise { const signed = await signNostrEvent({ kind: 9021, tags: [["h", channelId]], content: "", }); - await publishEvent(relayWsUrl(), signed); + await publishEvent(wsUrl, signed); } export async function loadChatMessages( channelId: string, + wsUrl = relayWsUrl(), ): Promise { - const events = await queryEvents(relayWsUrl(), { + const events = await queryEvents(wsUrl, { kinds: CHAT_MESSAGE_KINDS, "#h": [channelId], limit: MESSAGE_QUERY_LIMIT, @@ -73,6 +87,7 @@ export async function loadChatMessages( export async function sendChatMessage( channelId: string, content: string, + wsUrl = relayWsUrl(), ): Promise { const trimmed = content.trim(); if (!trimmed) throw new Error("Write a message first."); @@ -84,7 +99,7 @@ export async function sendChatMessage( tags: [["h", channelId]], content: trimmed, }); - await publishEvent(relayWsUrl(), signed); + await publishEvent(wsUrl, signed); return signed; } @@ -92,9 +107,10 @@ export function subscribeToChatChannel( channelId: string, onEvent: (event: NostrEvent) => void, onError: (error: Error) => void, + wsUrl = relayWsUrl(), ): () => void { return subscribeEvents( - relayWsUrl(), + wsUrl, { kinds: CHAT_MESSAGE_KINDS, "#h": [channelId], diff --git a/web/src/features/chat/lib/chat.ts b/web/src/features/chat/lib/chat.ts index d7798a9a76..790ac41f6d 100644 --- a/web/src/features/chat/lib/chat.ts +++ b/web/src/features/chat/lib/chat.ts @@ -96,6 +96,26 @@ export function selectAutoJoinChannel( return candidates.length === 1 ? candidates[0] : null; } +export function includeBootstrapGeneralChannel( + channels: ChatChannel[], + channelId: string | undefined, + isMember = false, +): ChatChannel[] { + if (!channelId || channels.some((channel) => channel.id === channelId)) { + return channels; + } + return [ + { + id: channelId, + name: "general", + type: "stream", + visibility: "open", + isMember, + }, + ...channels, + ]; +} + export function mergeChatMessages( current: NostrEvent[], incoming: NostrEvent[], diff --git a/web/src/features/chat/ui/ChatPage.tsx b/web/src/features/chat/ui/ChatPage.tsx index 9b5e834334..b183f4a6a9 100644 --- a/web/src/features/chat/ui/ChatPage.tsx +++ b/web/src/features/chat/ui/ChatPage.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router"; import { + ArrowLeft, FolderGit2, Hash, LoaderCircle, @@ -83,7 +84,17 @@ function MessageRow({ ); } -export function ChatPage() { +export function ChatPage({ + relayUrl, + spaceName, + bootstrapGeneralChannelId, + onBackToSpaces, +}: { + relayUrl: string; + spaceName: string; + bootstrapGeneralChannelId?: string; + onBackToSpaces?: () => void; +}) { const [catalog, setCatalog] = useState(null); const [catalogError, setCatalogError] = useState(""); const [catalogLoading, setCatalogLoading] = useState(true); @@ -96,34 +107,43 @@ export function ChatPage() { const catalogRequest = useRef | null>(null); const timelineEnd = useRef(null); - const refreshCatalog = useCallback(async (force = false) => { - setCatalogLoading(true); - setCatalogError(""); - if (force || !catalogRequest.current) { - catalogRequest.current = loadChatCatalog(); - } - try { - const loaded = await catalogRequest.current; - setCatalog(loaded); - setActiveChannelId((current) => { - if (loaded.channels.some((channel) => channel.id === current)) { - return current; - } - return ( - loaded.channels.find((channel) => channel.isMember)?.id ?? - loaded.channels[0]?.id ?? - null + const refreshCatalog = useCallback( + async (force = false) => { + setCatalogLoading(true); + setCatalogError(""); + if (force || !catalogRequest.current) { + catalogRequest.current = loadChatCatalog( + relayUrl, + bootstrapGeneralChannelId, ); - }); - } catch (error) { - setCatalogError(errorMessage(error)); - } finally { - setCatalogLoading(false); - } - }, []); + } + try { + const loaded = await catalogRequest.current; + setCatalog(loaded); + setActiveChannelId((current) => { + if (loaded.channels.some((channel) => channel.id === current)) { + return current; + } + return ( + loaded.channels.find((channel) => channel.isMember)?.id ?? + loaded.channels[0]?.id ?? + null + ); + }); + } catch (error) { + setCatalogError(errorMessage(error)); + } finally { + setCatalogLoading(false); + } + }, + [bootstrapGeneralChannelId, relayUrl], + ); useEffect(() => { - void refreshCatalog(); + catalogRequest.current = null; + setCatalog(null); + setActiveChannelId(null); + void refreshCatalog(true); }, [refreshCatalog]); const activeChannel = useMemo( @@ -163,9 +183,10 @@ export function ChatPage() { (error) => { if (!cancelled) setConnectionError(error.message); }, + relayUrl, ); - void loadChatMessages(activeChannel.id) + void loadChatMessages(activeChannel.id, relayUrl) .then((events) => { if (cancelled) return; setTimeline((current) => @@ -192,7 +213,7 @@ export function ChatPage() { cancelled = true; closeSubscription(); }; - }, [activeChannel]); + }, [activeChannel, relayUrl]); const latestMessageId = timeline.events[timeline.events.length - 1]?.id; useEffect(() => { @@ -206,7 +227,7 @@ export function ChatPage() { setJoiningChannelId(activeChannel.id); setCatalogError(""); try { - await joinChatChannel(activeChannel.id); + await joinChatChannel(activeChannel.id, relayUrl); catalogRequest.current = null; await refreshCatalog(true); setActiveChannelId(activeChannel.id); @@ -222,7 +243,7 @@ export function ChatPage() { setSending(true); setConnectionError(""); try { - const sent = await sendChatMessage(activeChannel.id, draft); + const sent = await sendChatMessage(activeChannel.id, draft, relayUrl); setTimeline((current) => ({ ...current, events: mergeChatMessages(current.events, [sent]), @@ -242,11 +263,22 @@ export function ChatPage() { >