diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4..058ab8f318 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -24,8 +24,8 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; -use buzz_core::tenant::{relay_url_authority, TenantContext}; -use buzz_db::{Db, DbConfig}; +use buzz_core::tenant::{normalize_host, relay_url_authority, TenantContext}; +use buzz_db::{AddHostAliasResult, Db, DbConfig}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -93,6 +93,43 @@ enum Command { #[arg(long)] relay_key: Option, }, + /// Manage this deployment's community (the one resolved from RELAY_URL). + Community { + #[command(subcommand)] + command: CommunityCommand, + }, +} + +#[derive(Subcommand)] +enum CommunityCommand { + /// Manage host aliases — additional hostnames that resolve to this + /// deployment's community without changing its canonical + /// `communities.host` (e.g. an in-cluster Service DNS name that has no + /// Host-header route to the community's external host). + Alias { + #[command(subcommand)] + command: AliasCommand, + }, +} + +#[derive(Subcommand)] +enum AliasCommand { + /// Add a host alias mapping to this deployment's community. + /// + /// Fails if the host is already some community's primary host, or + /// already aliased to a different community. + Add { + /// Additional hostname that should resolve to this community, e.g. + /// `sprout-relay.sprout.svc.cluster.local:3000`. + host: String, + }, + /// Remove a host alias from this deployment's community. + Remove { + /// Alias hostname to remove. + host: String, + }, + /// List host aliases for this deployment's community. + List, } #[derive(Subcommand)] @@ -152,6 +189,24 @@ async fn run(cli: Cli) -> Result { reconcile_channels(relay_key).await?; Ok(0) } + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::Add { host }, + }, + } => cmd_alias_add(host).await, + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::Remove { host }, + }, + } => cmd_alias_remove(host).await, + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::List, + }, + } => cmd_alias_list().await, } } @@ -286,6 +341,83 @@ async fn cmd_list_members() -> Result { Ok(0) } +async fn cmd_alias_add(host_arg: String) -> Result { + let host = normalize_host(&host_arg); + if host.is_empty() { + eprintln!("error: host must not be empty"); + return Ok(1); + } + + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + + match db + .add_community_host_alias(&host, tenant.community()) + .await? + { + AddHostAliasResult::Added(alias) => { + println!( + "added alias {} -> community {}", + alias.host, + tenant.community() + ); + Ok(0) + } + AddHostAliasResult::AlreadyAliased(existing) if existing == tenant.community() => { + println!("alias already exists: {host} (no change)"); + Ok(0) + } + AddHostAliasResult::AlreadyAliased(other) => { + eprintln!("error: host '{host}' is already aliased to a different community ({other})"); + Ok(2) + } + AddHostAliasResult::PrimaryHostCollision => { + eprintln!("error: host '{host}' is already a community's primary host"); + Ok(3) + } + } +} + +async fn cmd_alias_remove(host_arg: String) -> Result { + let host = normalize_host(&host_arg); + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + + if db + .remove_community_host_alias(&host, tenant.community()) + .await? + { + println!("removed alias {host}"); + Ok(0) + } else { + eprintln!("error: no alias '{host}' found for this community"); + Ok(2) + } +} + +async fn cmd_alias_list() -> Result { + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + let aliases = db.list_community_host_aliases(tenant.community()).await?; + + if aliases.is_empty() { + println!("(no host aliases)"); + return Ok(0); + } + + println!("{:<48} created_at", "host"); + println!("{}", "-".repeat(70)); + for alias in &aliases { + println!( + "{:<48} {}", + alias.host, + alias.created_at.format("%Y-%m-%dT%H:%M:%SZ") + ); + } + + Ok(0) +} + /// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`. fn validate_role(role: &str) -> std::result::Result<(), String> { match role { @@ -419,7 +551,7 @@ async fn connect_member_services() -> Result<(Db, Arc, Keys)> { async fn connect_db() -> Result { let db_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let db = Db::new(&DbConfig { database_url: db_url, ..DbConfig::default() @@ -432,10 +564,14 @@ async fn connect_db() -> Result { /// /// `buzz-admin` runs inside the relay container (`compose exec relay /// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same -/// single community against the durable `communities` host map. This is -/// deliberately NOT a default tenant: an unmapped host fails closed with an -/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is -/// single-community per invocation — there is no cross-community sweep. +/// single community the relay's own `HostResolver` would (`communities.host`, +/// falling back to `community_host_aliases`) — including when `RELAY_URL` +/// itself is only reachable as an alias, e.g. running `buzz-admin` from an +/// in-cluster host that has no primary `communities.host` row of its own. +/// This is deliberately NOT a default tenant: an unmapped host fails closed +/// with an error, mirroring the relay's own `bind_community` row-zero seam. +/// The CLI is single-community per invocation — there is no cross-community +/// sweep. async fn resolve_admin_tenant(db: &Db) -> Result { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -447,14 +583,17 @@ async fn resolve_admin_tenant(db: &Db) -> Result { // — and `wss://relay.example:8443` would resolve `relay.example`. Sharing // the helper keeps buzz-admin byte-identical to the community startup seeds. let host = relay_url_authority(&relay_url); - let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| { - anyhow::anyhow!( - "RELAY_URL host '{host}' is not mapped to a community.\n\ + let record = db + .lookup_community_by_host_or_alias(&host) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "RELAY_URL host '{host}' is not mapped to a community.\n\ buzz-admin operates on the configured relay's community; ensure the \ relay has started and seeded its community (or set RELAY_URL to a \ - mapped host)." - ) - })?; + mapped host or alias)." + ) + })?; Ok(TenantContext::resolved(record.id, record.host)) } diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..bfa601eef5 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -45,6 +45,12 @@ pub enum DbError { #[error("invalid data: {0}")] InvalidData(String), + /// The requested host is already reserved as a `community_host_aliases` + /// row for a different community, so it cannot become a primary + /// `communities.host` too. + #[error("host is already reserved as a community host alias: {0}")] + HostAliasCollision(String), + /// A stored timestamp value could not be interpreted. #[error("invalid timestamp: {0}")] InvalidTimestamp(i64), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..03cd307f65 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -266,6 +266,31 @@ pub struct CommunityRecord { pub host: String, } +/// Host alias row returned by [`Db::list_community_host_aliases`] and +/// [`Db::add_community_host_alias`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityHostAlias { + /// Normalized alias hostname. + pub host: String, + /// Community the alias resolves to. + pub community_id: CommunityId, + /// When the alias was created. + pub created_at: DateTime, +} + +/// Result of attempting to add a host alias via [`Db::add_community_host_alias`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddHostAliasResult { + /// The alias was newly created. + Added(CommunityHostAlias), + /// The host is already an alias — for the same community (no-op) or a + /// different one (caller must decide whether that's an error). + AlreadyAliased(CommunityId), + /// The host is already some community's primary `communities.host`; an + /// alias would create an ambiguous fallback the resolver never reaches. + PrimaryHostCollision, +} + /// Community row returned by idempotent community ensure/create operations. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnsuredCommunityRecord { @@ -710,6 +735,171 @@ impl Db { .transpose() } + /// Returns the community mapped to a normalized request host, checking + /// the primary `communities.host` map first and falling back to + /// `community_host_aliases`. + /// + /// This is the fallback-aware sibling of [`lookup_community_by_host`]; + /// [`crate::HostResolver`]'s `Db` impl (in `buzz-relay`) calls this one so + /// an in-cluster/alias host resolves the same community as the primary + /// host, without ever taking priority over it. Archived communities fail + /// closed via both paths, matching `lookup_community_by_host`. + /// + /// [`lookup_community_by_host`]: Self::lookup_community_by_host + pub async fn lookup_community_by_host_or_alias( + &self, + normalized_host: &str, + ) -> Result> { + if let Some(record) = self.lookup_community_by_host(normalized_host).await? { + return Ok(Some(record)); + } + + let row = sqlx::query( + r#" + SELECT c.id, c.host + FROM community_host_aliases a + JOIN communities c ON c.id = a.community_id + WHERE lower(a.host) = lower($1) + AND c.archived_at IS NULL + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Returns whether `normalized_host` is reserved as a host alias for any + /// community, regardless of that community's archived state. + /// + /// Operator-plane counterpart to [`lookup_community_by_host_for_management`]: + /// a host stays reserved as an alias even if the community it points to is + /// archived (the alias-collision trigger from migration 0025 checks + /// `communities` unconditionally too), so an availability check that only + /// consulted the fallback-aware, archived-filtering + /// [`lookup_community_by_host_or_alias`] would report an alias-reserved + /// host as available and let a create attempt fail on the trigger instead + /// of the normal "host taken" response. + /// + /// [`lookup_community_by_host_for_management`]: Self::lookup_community_by_host_for_management + /// [`lookup_community_by_host_or_alias`]: Self::lookup_community_by_host_or_alias + pub async fn community_host_alias_exists(&self, normalized_host: &str) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_host_aliases WHERE lower(host) = lower($1))", + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + Ok(exists) + } + + /// Adds a host alias resolving to `community_id`. + /// + /// Checks upfront for a collision with any community's primary host so + /// the common case returns a typed [`AddHostAliasResult`] instead of a + /// raw constraint-violation error; migration 0025's trigger is the + /// belt-and-suspenders guard against the same race at the database level. + pub async fn add_community_host_alias( + &self, + normalized_host: &str, + community_id: CommunityId, + ) -> Result { + if self + .lookup_community_by_host_for_management(normalized_host) + .await? + .is_some() + { + return Ok(AddHostAliasResult::PrimaryHostCollision); + } + + let row = sqlx::query( + r#" + INSERT INTO community_host_aliases (host, community_id) + VALUES ($1, $2) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING host, community_id, created_at + "#, + ) + .bind(normalized_host) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + if let Some(row) = row { + return Ok(AddHostAliasResult::Added(CommunityHostAlias { + host: row.try_get("host")?, + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + created_at: row.try_get("created_at")?, + })); + } + + let existing_community_id: Uuid = sqlx::query_scalar( + "SELECT community_id FROM community_host_aliases WHERE lower(host) = lower($1)", + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + Ok(AddHostAliasResult::AlreadyAliased(CommunityId::from_uuid( + existing_community_id, + ))) + } + + /// Removes a host alias, scoped to `community_id` so a caller can only + /// remove aliases belonging to the community it resolved to (mirrors the + /// scoping on [`remove_relay_member`](Self::remove_relay_member)). + /// + /// Returns `true` if a row was removed, `false` if no alias with that + /// host existed for this community. + pub async fn remove_community_host_alias( + &self, + normalized_host: &str, + community_id: CommunityId, + ) -> Result { + let result = sqlx::query( + "DELETE FROM community_host_aliases WHERE lower(host) = lower($1) AND community_id = $2", + ) + .bind(normalized_host) + .bind(community_id.as_uuid()) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Lists host aliases for `community_id`, oldest first. + pub async fn list_community_host_aliases( + &self, + community_id: CommunityId, + ) -> Result> { + let rows = sqlx::query( + r#" + SELECT host, community_id, created_at + FROM community_host_aliases + WHERE community_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(CommunityHostAlias { + host: row.try_get("host")?, + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + created_at: row.try_get("created_at")?, + }) + }) + .collect() + } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. /// /// This is an operator-plane helper, not a tenant-scoped data-plane read: @@ -827,6 +1017,14 @@ impl Db { /// This is the startup/config seeding path for N=1 deployments. Migrations /// create the schema only; deployment-specific hosts are not hardcoded into /// schema history. + /// + /// A host already reserved in `community_host_aliases` never gets a + /// `communities.host` row of its own, so `ON CONFLICT (lower(host))` + /// can't catch that case — migration 0025's `communities_no_alias_collision` + /// trigger rejects the `INSERT` instead, raising `23505`. That's caught + /// below and surfaced as [`DbError::HostAliasCollision`] so callers (the + /// operator provisioning API's legacy convergence path in particular) get + /// a distinguishable, mappable error instead of a raw database error. pub async fn ensure_configured_community( &self, normalized_host: &str, @@ -841,7 +1039,15 @@ impl Db { ) .bind(normalized_host) .fetch_one(&self.pool) - .await?; + .await; + + let row = match row { + Ok(row) => row, + Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23505") => { + return Err(DbError::HostAliasCollision(normalized_host.to_string())); + } + Err(err) => return Err(err.into()), + }; let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; @@ -859,6 +1065,16 @@ impl Db { /// Holds a per-owner advisory lock while enforcing the ownership limit. /// Identical create retries return the original record; host collisions and /// limit failures remain distinguishable to the operator API. + /// + /// A host already reserved in `community_host_aliases` collides too: + /// `ON CONFLICT (lower(host))` only catches conflicts against + /// `communities`' own unique index, so an alias collision instead trips + /// migration 0025's `communities_no_alias_collision` trigger, which + /// raises a `23505` (`unique_violation`) from the `BEFORE INSERT` and + /// never reaches `ON CONFLICT`. That's caught below and folded into the + /// same [`CreateCommunityWithOwnerResult::HostExists`] outcome, so + /// callers see one "host taken" case regardless of which table claimed + /// it first. pub async fn create_community_with_owner( &self, normalized_host: &str, @@ -884,7 +1100,16 @@ impl Db { ) .bind(normalized_host) .fetch_optional(&mut *tx) - .await?; + .await; + + let row = match row { + Ok(row) => row, + Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23505") => { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::HostExists); + } + Err(err) => return Err(err.into()), + }; let (id, host) = if let Some(row) = row { let id: Uuid = row.try_get("id")?; @@ -3929,7 +4154,7 @@ mod tests { use sqlx::{Acquire, PgPool}; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_db() -> Db { let database_url = @@ -4719,6 +4944,157 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_resolves_to_same_community_as_primary_host() { + let db = setup_db().await; + let host = format!("alias-primary-{}.example", Uuid::new_v4().simple()); + let alias_host = format!("alias-clusterlocal-{}.example", Uuid::new_v4().simple()); + let owner = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let added = db + .add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + assert!(matches!(added, AddHostAliasResult::Added(_))); + + let resolved = db + .lookup_community_by_host_or_alias(&alias_host) + .await + .expect("resolve alias") + .expect("alias resolves to a community"); + assert_eq!(resolved.id, created.id); + + // Case-insensitive, same as the primary-host index. + let resolved_upper = db + .lookup_community_by_host_or_alias(&alias_host.to_ascii_uppercase()) + .await + .expect("resolve upper-case alias") + .expect("alias resolves regardless of case"); + assert_eq!(resolved_upper.id, created.id); + + // A primary host must still resolve through the same fallback-aware + // lookup (the fallback must never shadow it). + let resolved_primary = db + .lookup_community_by_host_or_alias(&host) + .await + .expect("resolve primary host") + .expect("primary host still resolves"); + assert_eq!(resolved_primary.id, created.id); + + let listed = db + .list_community_host_aliases(created.id) + .await + .expect("list aliases"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].host, alias_host); + + assert!( + db.remove_community_host_alias(&alias_host, created.id) + .await + .expect("remove alias"), + "remove reports the row was deleted" + ); + assert!( + db.lookup_community_by_host_or_alias(&alias_host) + .await + .expect("resolve after removal") + .is_none(), + "removed alias must not resolve" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unknown_host_fails_closed_even_with_aliases_present() { + let db = setup_db().await; + let host = format!("unknown-fence-{}.example", Uuid::new_v4().simple()); + let owner = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let alias_host = format!("unknown-fence-alias-{}.example", Uuid::new_v4().simple()); + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + db.add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let unmapped = format!("totally-unmapped-{}.example", Uuid::new_v4().simple()); + assert!( + db.lookup_community_by_host_or_alias(&unmapped) + .await + .expect("lookup unmapped host") + .is_none(), + "a host that is neither a primary host nor an alias must fail closed" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_colliding_with_a_primary_host_is_rejected() { + let db = setup_db().await; + let host_a = format!("collision-a-{}.example", Uuid::new_v4().simple()); + let host_b = format!("collision-b-{}.example", Uuid::new_v4().simple()); + let owner_a = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let owner_b = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + let created_a = db + .create_community_with_owner(&host_a, owner_a) + .await + .expect("create community A"); + let CreateCommunityWithOwnerResult::Created(created_a) = created_a else { + panic!("expected new community A"); + }; + let created_b = db + .create_community_with_owner(&host_b, owner_b) + .await + .expect("create community B"); + let CreateCommunityWithOwnerResult::Created(created_b) = created_b else { + panic!("expected new community B"); + }; + + // The typed application-level guard rejects it... + let result = db + .add_community_host_alias(&host_b, created_a.id) + .await + .expect("add alias attempt"); + assert_eq!(result, AddHostAliasResult::PrimaryHostCollision); + + // ...and the database trigger is the belt-and-suspenders guard + // against the same collision if the application check is bypassed + // (e.g. a direct INSERT, as a misbehaving caller might attempt). + let trigger_result = + sqlx::query("INSERT INTO community_host_aliases (host, community_id) VALUES ($1, $2)") + .bind(&host_b) + .bind(created_a.id.as_uuid()) + .execute(&db.pool) + .await; + assert!( + trigger_result.is_err(), + "the database trigger must reject an alias colliding with a primary host" + ); + + // Community B's own host must still resolve to community B, not A. + let resolved = db + .lookup_community_by_host_or_alias(&host_b) + .await + .expect("resolve host B") + .expect("host B still resolves"); + assert_eq!(resolved.id, created_b.id); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn create_community_with_owner_is_atomic_and_create_only() { @@ -4782,6 +5158,48 @@ mod tests { ); } + /// A host already reserved via `community_host_aliases` never has a + /// `communities.host` row of its own, so `ON CONFLICT (lower(host))` + /// can't catch the collision — it's the `communities_no_alias_collision` + /// trigger that rejects the `INSERT`. That must still surface as the + /// typed `HostExists` outcome, not a raw database error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_treats_alias_reserved_host_as_host_exists() { + let db = setup_db().await; + let owner_host = format!("alias-owner-{}.example", Uuid::new_v4().simple()); + let owner = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + let created = db + .create_community_with_owner(&owner_host, owner) + .await + .expect("create owning community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let alias_host = format!("alias-target-{}.example", Uuid::new_v4().simple()); + db.add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let other = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let collision = db + .create_community_with_owner(&alias_host, other) + .await + .expect("collision result should not be a raw DB error"); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); + + // The failed create must not have poisoned the pool connection or + // left the alias's owning community mutated. + let aliases = db + .list_community_host_aliases(created.id) + .await + .expect("list aliases after collision"); + assert_eq!(aliases.len(), 1); + assert_eq!(aliases[0].host, alias_host); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unarchive_community_owned_by_restores_admission_idempotently() { @@ -4926,6 +5344,36 @@ mod tests { assert_eq!(second.host, host); } + /// The legacy convergence path (used by the operator API's `create_only: + /// false` mode and by startup seeding) must also translate an + /// alias-reserved host into a distinguishable error rather than letting + /// migration 0025's trigger raise a raw database error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_rejects_alias_reserved_host() { + let db = setup_db().await; + let owner_host = format!("ensure-alias-owner-{}.example", Uuid::new_v4().simple()); + + let owner = db + .ensure_configured_community(&owner_host) + .await + .expect("create owning community"); + + let alias_host = format!("ensure-alias-target-{}.example", Uuid::new_v4().simple()); + db.add_community_host_alias(&alias_host, owner.id) + .await + .expect("add alias"); + + let err = db + .ensure_configured_community(&alias_host) + .await + .expect_err("alias-reserved host must not silently become a primary host"); + assert!( + matches!(err, DbError::HostAliasCollision(ref h) if h == &alias_host), + "expected HostAliasCollision, got {err:?}" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn list_communities_owned_by_returns_only_owner_rows() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..eaefbf48ec 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -347,6 +347,7 @@ mod tests { "push_gateway_delivery_auth_replays", "push_gateway_delivery_request_replays", "product_feedback", + "community_host_aliases", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -560,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +880,18 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // Host aliases: additional hostnames resolving to an existing + // community, guarded both directions against collision with a + // primary communities.host. + assert_eq!(migrations[24].version, 25); + let host_aliases = migrations[24].sql.as_str(); + assert!(host_aliases.contains("CREATE TABLE community_host_aliases")); + assert!(host_aliases + .contains("CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host))")); + assert!(host_aliases.contains("trg_community_host_aliases_no_primary_collision")); + assert!(host_aliases.contains("trg_communities_no_alias_collision")); + assert!(host_aliases.contains("'community_host_aliases'")); } #[test] diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..65eab5504e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -489,10 +489,21 @@ pub async fn community_availability( .await .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + // A host can also be reserved as an alias of some other community (never + // as its own — the create path can't collide with its own alias). Check + // this even when `existing` is `Some` so the response is well-formed + // either way; the create/alias-add paths are the source of truth for + // which reservation wins. + let alias_taken = state + .db + .community_host_alias_exists(&normalized_host) + .await + .map_err(|e| internal_error(&format!("check community host alias reservation: {e}")))?; + Ok(Json(serde_json::json!({ "host": query.host, "normalized_host": normalized_host, - "available": existing.is_none(), + "available": existing.is_none() && !alias_taken, "community_id": existing.map(|record| record.id.to_string()), }))) } @@ -802,6 +813,62 @@ mod tests { assert_eq!(json.get("available").and_then(Value::as_bool), Some(true)); } + /// A host reserved only via `community_host_aliases` (never + /// `communities.host` itself) must report `available: false` — otherwise + /// a caller sees "available", then the create path's alias-collision + /// trigger turns the create into an internal error instead of the normal + /// "host taken" response. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_reserved_host_is_not_available() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + let primary_host = format!("alias-owner-{}.example", Uuid::new_v4().simple()); + let owner_pubkey = "1111111111111111111111111111111111111111111111111111111111111a"; + let created = state + .db + .create_community_with_owner(&primary_host, owner_pubkey) + .await + .expect("create owning community"); + let buzz_db::CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let alias_host = format!("alias-target-{}.example", Uuid::new_v4().simple()); + state + .db + .add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let query = format!("host={alias_host}"); + let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}"); + let auth = nip98_auth_header(&operator, &url, "GET", None); + + let response = build_router(state) + .oneshot( + Request::builder() + .uri(format!("/operator/communities/availability?{query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("available").and_then(Value::as_bool), + Some(false), + "alias-reserved host must not report available; got {json:?}" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_list_owned_communities() { diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea..87cd10a8ed 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -318,11 +318,13 @@ pub async fn provision_community( // Legacy convergence mode remains available to deployment operators and // startup tooling. Clients provisioning on behalf of end users must use // create_only so an existing owner can never be rotated by a create race. - let record = state - .db - .ensure_configured_community(&request.host) - .await - .map_err(|e| format!("failed to create community: {e}"))?; + let record = match state.db.ensure_configured_community(&request.host).await { + Ok(record) => record, + Err(buzz_db::DbError::HostAliasCollision(_)) => { + return Err("community already exists".to_string()); + } + Err(e) => return Err(format!("failed to create community: {e}")), + }; if let Some(owner_hex) = &initial_owner { state diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 88b75f7d6e..9467b922ca 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -10,9 +10,10 @@ //! //! This module owns the *seam* (the [`HostResolver`] trait and the fail-closed //! [`bind_community`] helper) and the relay-side call site. The DB-backed -//! implementation that queries the `communities` table lives in `buzz-db` -//! (`Db::resolve_host`); the relay depends on the trait, not the query, so the -//! binding is testable without a database. +//! implementation that queries the `communities` table (falling back to +//! `community_host_aliases`) lives in `buzz-db` +//! (`Db::lookup_community_by_host_or_alias`); the relay depends on the trait, +//! not the query, so the binding is testable without a database. use buzz_core::tenant::{normalize_host, CommunityId, TenantContext}; @@ -123,14 +124,26 @@ pub async fn bind_deployment_community( pub use buzz_core::tenant::relay_url_authority; /// Production [`HostResolver`]: the relay resolves hosts against the durable -/// `communities` host map in Postgres. +/// `communities` host map in Postgres, falling back to `community_host_aliases` +/// when the host has no primary-host row. /// /// This is the *only* place the relay couples the row-zero seam to buzz-db. The /// trait keeps `bind_community` and every call site database-free and testable; -/// this impl is the thin adapter from buzz-db's `lookup_community_by_host` +/// this impl is the thin adapter from buzz-db's `lookup_community_by_host_or_alias` /// (which returns a `CommunityRecord`) to the seam's `CommunityId`. A lookup -/// that succeeds but finds no row is `Ok(None)` — fail-closed, never a default -/// tenant; a lookup that *fails* (DB unreachable) is `Err`, also fail-closed. +/// that succeeds but finds no row (in either table) is `Ok(None)` — fail-closed, +/// never a default tenant; a lookup that *fails* (DB unreachable) is `Err`, +/// also fail-closed. +/// +/// The alias fallback exists for deployments where a client can only reach the +/// relay through a hostname distinct from the community's primary host (e.g. +/// an in-cluster Service DNS name that a mesh sidecar routes by Host header, +/// which has no `communities.host` row of its own). Resolving via an alias +/// still returns only the `CommunityId` — [`bind_community`] binds the +/// *request's* normalized host into `TenantContext::host()`, never the +/// primary host a lookup happened to resolve through. NIP-98 `u`-URL and +/// NIP-42 `relay` checks therefore keep comparing against the host the client +/// actually used, alias or not. impl HostResolver for buzz_db::Db { type Error = buzz_db::DbError; @@ -139,7 +152,7 @@ impl HostResolver for buzz_db::Db { normalized_host: &str, ) -> Result, Self::Error> { Ok(self - .lookup_community_by_host(normalized_host) + .lookup_community_by_host_or_alias(normalized_host) .await? .map(|record| record.id)) } diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..aedfcc2dbb 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -30,6 +30,14 @@ //! one DB, two communities, provably isolated by `community_id` derived from the //! host — never from caller input. //! +//! The `host_aliases` module additionally needs a third host, +//! `RELAY_URL_ALIAS_A`, seeded as a `community_host_aliases` row for A (not a +//! `communities.host` row) via: +//! +//! ```text +//! RELAY_URL=http://a.localhost:3000 buzz-admin community alias add alias-a.localhost:3000 +//! ``` +//! //! # Status of each row //! //! A row is `todo!()`-stubbed until the lane it depends on lands on the @@ -60,6 +68,19 @@ fn url_unknown() -> String { .unwrap_or_else(|_| "http://unknown.localhost:3000".to_string()) } +/// A host that resolves to community A only via `community_host_aliases`, +/// never `communities.host` directly (see `host_aliases` module below). +/// +/// Requires seeding once against the running relay before the `host_aliases` +/// tests: `RELAY_URL= buzz-admin community alias add +/// ` — mirrors how `RELAY_URL_A`/`RELAY_URL_B` require +/// a pre-seeded two-community DB, just via the new alias table instead of +/// `communities.host`. +fn url_alias_a() -> String { + std::env::var("RELAY_URL_ALIAS_A") + .unwrap_or_else(|_| "http://alias-a.localhost:3000".to_string()) +} + /// Marker for a conformance obligation whose lane has not yet landed on the /// integration branch. Centralizes the "not yet wired" panic so the harvest of /// remaining work is one grep: `rg pending_lane conformance_multitenant.rs`. @@ -1641,6 +1662,199 @@ mod channels_membership { } } +// --------------------------------------------------------------------------- +// Host aliases: `community_host_aliases` fallback (relay-wiring) +// --------------------------------------------------------------------------- +mod host_aliases { + use super::*; + + use buzz_test_client::BuzzTestClient; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Convert any base form to `ws(s)://` for WS connect. + fn to_ws(base: &str) -> String { + if base.starts_with("ws://") || base.starts_with("wss://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("https://", "wss://") + .replace("http://", "ws://") + .trim_end_matches('/') + .to_string() + } + } + + /// Convert any base form to `http(s)://` for REST. + fn to_http(base: &str) -> String { + if base.starts_with("http://") || base.starts_with("https://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + } + + /// Same shape as `channels_membership::create_channel`: create an + /// open/stream channel in the community resolved by `http_base`. + async fn create_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String { + let client = reqwest::Client::new(); + let pubkey_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("conformance-alias-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = client + .post(format!("{http_base}/events")) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-channel"); + assert!( + resp.status().is_success(), + "create-channel HTTP failed against {http_base}: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("parse create-channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "create-channel not accepted against {http_base}: {body}" + ); + channel_uuid.to_string() + } + + async fn post_kind9( + client: &mut BuzzTestClient, + keys: &Keys, + channel_id: &str, + content: &str, + ) -> String { + let h_tag = Tag::parse(["h", channel_id]).unwrap(); + let event = EventBuilder::new(Kind::Custom(9), content) + .tags([h_tag]) + .sign_with_keys(keys) + .unwrap(); + let id_hex = event.id.to_hex(); + let ok = client.send_event(event).await.expect("send kind:9"); + assert!(ok.accepted, "kind:9 not accepted: {}", ok.message); + id_hex + } + + async fn query_kind9_in_channel( + http_base: &str, + pubkey_hex: &str, + channel_id: &str, + ) -> Vec { + let client = reqwest::Client::new(); + let filters = serde_json::json!([{ + "kinds": [9], + "#h": [channel_id], + "limit": 100, + }]); + let resp = client + .post(format!("{http_base}/query")) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filters).unwrap()) + .send() + .await + .unwrap_or_else(|e| panic!("POST /query against {http_base} failed: {e}")); + assert!( + resp.status().is_success(), + "POST /query against {http_base} returned {}", + resp.status() + ); + resp.json().await.expect("parse /query JSON") + } + + /// Obligation: a host mapped only through `community_host_aliases` (never + /// `communities.host` directly) binds to the aliased community exactly + /// like a primary host would, and that binding does not leak into any + /// other community on the same relay process. + /// + /// This is the alias-table analogue of + /// [`super::channels_membership::same_channel_uuid_in_two_communities_is_isolated`]: + /// same shared-UUID-channel, distinct-content-per-community setup, except + /// the "A" side of the request now arrives over the alias host + /// (`url_alias_a()`) instead of A's primary host. If `resolve_host` ever + /// let the alias fall through to a default tenant, or resolved it to the + /// wrong community, the channel/message written over the alias host + /// would either fail to appear under community A's primary host, or + /// (worse) appear under B. + /// + /// # Mutate-bite + /// + /// Drop the alias fallback from `Db::lookup_community_by_host_or_alias` + /// (checking only `communities.host`) → the alias host's `bind_community` + /// call returns `UnmappedHost`, the `/events` POST over the alias host + /// 404s, and `create_channel`/`post_kind9` panic on non-success status. + /// Route the fallback to the *wrong* community instead of `NULL`-safe + /// `None` → the message posted "as A" over the alias host shows up under + /// B's query instead of A's, tripping the cross-leak assertion below. + #[tokio::test] + #[ignore] + async fn alias_host_binds_to_aliased_community_without_leaking_into_another() { + let http_alias_a = to_http(&url_alias_a()); + let ws_alias_a = to_ws(&url_alias_a()); + let http_a = to_http(&url_a()); + let http_b = to_http(&url_b()); + + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Create the channel over the alias host — if the alias resolved to + // no community (or the wrong one), this create would either 404 or + // land somewhere other than community A. + let channel_uuid = uuid::Uuid::new_v4(); + let chan = create_channel(&http_alias_a, &keys, channel_uuid).await; + + let content = format!("alias-bound message {channel_uuid}"); + let mut client_alias_a = BuzzTestClient::connect(&ws_alias_a, &keys) + .await + .expect("connect over alias host"); + let _id = post_kind9(&mut client_alias_a, &keys, &chan, &content).await; + client_alias_a + .disconnect() + .await + .expect("disconnect alias connection"); + + // Settle time, matching every other row in this file. + tokio::time::sleep(Duration::from_millis(500)).await; + + // (1) The alias host binds to community A: A's *primary* host sees + // the channel and message the alias host wrote. + let hits_a = query_kind9_in_channel(&http_a, &pubkey_hex, &chan).await; + assert_eq!( + hits_a.len(), + 1, + "community A's primary host did not see the message written over \ + its alias host — alias did not bind to A. hits: {hits_a:?}" + ); + assert_eq!( + hits_a[0]["content"].as_str().unwrap_or(""), + content, + "community A saw a message but not the one written over the alias host" + ); + + // (2) No leak into community B: B's host must see nothing for this + // channel id — the alias must not have bound to B, nor to some + // default/shared tenant both A and B's queries would also hit. + let hits_b = query_kind9_in_channel(&http_b, &pubkey_hex, &chan).await; + assert!( + hits_b.is_empty(), + "community B saw a message written over community A's alias host — \ + the alias leaked cross-community. hits: {hits_b:?}" + ); + } +} + // --------------------------------------------------------------------------- // Workflows / runs / approvals / webhooks / schedules (Mari+Max) // --------------------------------------------------------------------------- diff --git a/migrations/0025_community_host_aliases.sql b/migrations/0025_community_host_aliases.sql new file mode 100644 index 0000000000..a573232a4a --- /dev/null +++ b/migrations/0025_community_host_aliases.sql @@ -0,0 +1,81 @@ +-- Host aliases: map additional hostnames onto an existing community without +-- touching `communities.host`. Conformance: row zero (`resolve_host`) still +-- checks `communities.host` first (crates/buzz-relay/src/tenant.rs); this +-- table is consulted only as a fallback, so an alias can never shadow or +-- outrank a primary host, and it never rewrites `TenantContext::host()` — the +-- request's own host stays authoritative for NIP-98 `u`-URL / NIP-42 `relay` +-- checks. +-- +-- Motivating case: in-cluster clients (e.g. Blox workstations) reach the +-- relay through a cluster-local Service DNS name that the Envoy mesh routes +-- by Host header, distinct from the community's externally-facing host. An +-- alias lets that cluster-local host resolve to the same community without +-- renaming the community's canonical host, which would rebind +-- `TenantContext::host()` and break existing NIP-98/NIP-42 checks for +-- external clients still using the original host. +-- +-- Like `communities`, this table is listed in `_operator_global_tables`: +-- host lookup must be globally unique across all communities (a host maps to +-- at most one community, alias or primary), so its unique index cannot lead +-- with `community_id`. +CREATE TABLE community_host_aliases ( + host VARCHAR(255) NOT NULL, + community_id UUID NOT NULL REFERENCES communities(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host)); +CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases (community_id); + +-- Guard both directions so an alias can never collide with a primary +-- `communities.host`, regardless of insertion order: a new/updated alias +-- cannot claim an existing primary host, and a new/renamed community cannot +-- claim an existing alias host. Cross-table checks can't be expressed as a +-- CHECK constraint, hence triggers (mirrors `channels_community_id_immutable` +-- in migration 0001). +-- +-- Both triggers take a `pg_advisory_xact_lock` on the SAME key — derived only +-- from `lower(host)`, not the table — before their `EXISTS` read. Without it, +-- a community-create and an alias-add for the same host can run concurrently: +-- each runs its `EXISTS` check against the other's not-yet-committed row (MVCC +-- under READ COMMITTED does not see it), both pass, and both commit, leaving +-- the same host claimed on both sides — `lookup_community_by_host_or_alias` +-- would then silently prefer the primary row and the alias would never be +-- reachable, defeating the one-host-one-community invariant. The lock +-- serializes the two paths: the loser blocks until the winner's transaction +-- ends, then re-runs its `EXISTS` check under a fresh statement snapshot that +-- sees the winner's committed row (or absence of one). Lock key domain +-- 'buzz_host_claim:' is distinct from 'buzz_channel_ttl:' (migration 0024) and +-- 'buzz_push_gate:' (migration 0023). +CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); + IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community primary host', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_community_host_aliases_no_primary_collision + BEFORE INSERT OR UPDATE ON community_host_aliases + FOR EACH ROW EXECUTE FUNCTION community_host_aliases_no_primary_collision(); + +CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); + IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community host alias', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_communities_no_alias_collision + BEFORE INSERT OR UPDATE ON communities + FOR EACH ROW EXECUTE FUNCTION communities_no_alias_collision(); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('community_host_aliases', 'host alias map spans communities by design; global host uniqueness is enforced against communities.host via trigger, mirroring the communities registry itself'); diff --git a/schema/schema.sql b/schema/schema.sql index 1fd13941be..3ab7e32e48 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -61,6 +61,65 @@ CREATE TABLE communities ( CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); +-- ── Community host aliases ─────────────────────────────────────────────────────── +-- Additional hostnames that resolve to an existing community without +-- touching `communities.host`. `resolve_host` checks `communities.host` +-- first and falls back here (crates/buzz-relay/src/tenant.rs); an alias can +-- never shadow a primary host and never rewrites `TenantContext::host()`. +-- +-- Motivating case: in-cluster clients reach the relay through a +-- cluster-local Service DNS name that the mesh routes by Host header, +-- distinct from the community's externally-facing host. +-- +-- Like `communities`, this table is OPERATOR-GLOBAL: host lookup must be +-- globally unique across all communities, so its unique index cannot lead +-- with `community_id`. Listed in the lint allowlist below. + +CREATE TABLE community_host_aliases ( + host VARCHAR(255) NOT NULL, + community_id UUID NOT NULL REFERENCES communities(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host)); +CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases (community_id); + +-- Guard both directions so an alias can never collide with a primary +-- communities.host, regardless of insertion order. Both triggers take a +-- pg_advisory_xact_lock keyed only on lower(host) (not the table) before +-- their EXISTS read, so a concurrent community-create and alias-add for the +-- same host serialize instead of both passing against each other's +-- not-yet-committed row (see migration 0025). +CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); + IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community primary host', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_community_host_aliases_no_primary_collision + BEFORE INSERT OR UPDATE ON community_host_aliases + FOR EACH ROW EXECUTE FUNCTION community_host_aliases_no_primary_collision(); + +CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); + IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community host alias', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_communities_no_alias_collision + BEFORE INSERT OR UPDATE ON communities + FOR EACH ROW EXECUTE FUNCTION communities_no_alias_collision(); + -- ── Channels ────────────────────────────────────────────────────────────────── -- Conformance: "Channels and channel membership". `community_id` immutable. -- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique: @@ -744,7 +803,8 @@ CREATE TABLE _operator_global_tables ( INSERT INTO _operator_global_tables (table_name, reason) VALUES ('communities', 'the tenant registry itself; id IS the community key'), ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'), - ('_operator_global_tables', 'the registry table itself'); + ('_operator_global_tables', 'the registry table itself'), + ('community_host_aliases', 'host alias map spans communities by design; global host uniqueness is enforced against communities.host via trigger, mirroring the communities registry itself'); -- NIP-PL effective lease state and durable wake outbox. Every key is led by -- community_id: client-provided origin is confirmation only, never routing. CREATE TABLE push_leases (