diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d4c76b2cc..fe45f6f4c 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1481,6 +1481,27 @@ impl Db { Ok(()) } + /// Rebind the durable loopback community to this single-node process's + /// current ephemeral authority while preserving its stable UUID and data. + /// SQLite-only: production deployments never rewrite tenant host mappings. + pub async fn rebind_single_node_community_host( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + if matches!(&self.backend, DbBackend::SQLite(_)) { + return sqlite::rebind_single_node_community_host( + self.sqlite_pool()?, + normalized_host, + owner_pubkey, + ) + .await; + } + Err(DbError::UnsupportedBackend( + "single-node community rebind requires SQLite", + )) + } + /// Ensure a configured community host exists and return its row. /// /// This is the startup/config seeding path for N=1 deployments. Migrations diff --git a/crates/buzz-db/src/sqlite.rs b/crates/buzz-db/src/sqlite.rs index c392924fc..9e9676b96 100644 --- a/crates/buzz-db/src/sqlite.rs +++ b/crates/buzz-db/src/sqlite.rs @@ -525,6 +525,85 @@ pub(crate) async fn community_of_channel( .transpose() } +pub(crate) async fn rebind_single_node_community_host( + pool: &SqlitePool, + normalized_host: &str, + owner_pubkey: &str, +) -> Result> { + let mut tx = pool.begin().await?; + + // Defect-era databases may contain several loopback communities, one per + // ephemeral port. Prefer a community owned by this desktop identity, then + // the one carrying the most events. This preserves the most useful UUID and + // its channels/messages while making the current Host header resolvable. + let row = sqlx::query( + r#"SELECT c.id, c.host + FROM communities c + LEFT JOIN relay_members rm + ON rm.community_id = c.id + AND lower(rm.pubkey) = lower(?1) + AND rm.role = 'owner' + LEFT JOIN events e ON e.community_id = c.id + WHERE c.archived_at IS NULL + AND c.host LIKE '127.0.0.1:%' + GROUP BY c.id, c.host, c.created_at + ORDER BY (rm.pubkey IS NOT NULL) DESC, count(e.id) DESC, c.created_at ASC, c.id ASC + LIMIT 1"#, + ) + .bind(owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(row) = row else { + tx.commit().await?; + return Ok(None); + }; + let record = community_record(row)?; + if record.host.eq_ignore_ascii_case(normalized_host) { + tx.commit().await?; + return Ok(Some(record)); + } + + // The OS may reuse a port that belongs to another stale defect-era row. + // Swap that collision to the selected row's old authority transactionally, + // using a temporary unique host to satisfy the case-insensitive constraint. + if let Some(collision_id) = sqlx::query_scalar::<_, String>( + "SELECT id FROM communities WHERE host = ?1 COLLATE NOCASE AND id <> ?2", + ) + .bind(normalized_host) + .bind(record.id.as_uuid().to_string()) + .fetch_optional(&mut *tx) + .await? + { + let temporary_host = format!("rebind-{}.invalid", Uuid::new_v4()); + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(&collision_id) + .bind(&temporary_host) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(record.id.as_uuid().to_string()) + .bind(normalized_host) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(collision_id) + .bind(&record.host) + .execute(&mut *tx) + .await?; + } else { + sqlx::query("UPDATE communities SET host = ?2 WHERE id = ?1") + .bind(record.id.as_uuid().to_string()) + .bind(normalized_host) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Some(CommunityRecord { + id: record.id, + host: normalized_host.to_string(), + })) +} + pub(crate) async fn ensure_configured_community( pool: &SqlitePool, normalized_host: &str, @@ -4340,6 +4419,110 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + #[tokio::test] + async fn rebind_single_node_community_preserves_best_owner_uuid() { + let pool = connect("sqlite::memory:").await.unwrap(); + let owner = "ab".repeat(32); + let older = ensure_configured_community(&pool, "127.0.0.1:41001") + .await + .unwrap(); + let best = ensure_configured_community(&pool, "127.0.0.1:41002") + .await + .unwrap(); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES (?1, ?2, 'owner')", + ) + .bind(best.id.as_uuid().to_string()) + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + for id in [vec![1_u8; 32], vec![2_u8; 32]] { + sqlx::query("INSERT INTO events (community_id,id,pubkey,created_at,kind,tags_json,content,sig,received_at,event_json) VALUES (?1,?2,?3,1,9,'[]','marker',?4,1,'{}')") + .bind(best.id.as_uuid().to_string()) + .bind(id) + .bind(vec![3_u8; 32]) + .bind(vec![4_u8; 64]) + .execute(&pool) + .await + .unwrap(); + } + + let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:41999", &owner) + .await + .unwrap() + .unwrap(); + assert_eq!(rebound.id, best.id); + assert_eq!(rebound.host, "127.0.0.1:41999"); + assert!(lookup_community_by_host(&pool, "127.0.0.1:41002") + .await + .unwrap() + .is_none()); + assert_eq!( + lookup_community_by_host(&pool, "127.0.0.1:41001") + .await + .unwrap() + .unwrap() + .id, + older.id + ); + } + + #[tokio::test] + async fn rebind_single_node_community_is_idempotent_for_current_host() { + let pool = connect("sqlite::memory:").await.unwrap(); + let current = ensure_configured_community(&pool, "127.0.0.1:42000") + .await + .unwrap(); + let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:42000", &"cd".repeat(32)) + .await + .unwrap() + .unwrap(); + assert_eq!(rebound.id, current.id); + } + + #[tokio::test] + async fn rebind_single_node_community_swaps_stale_port_collision() { + let pool = connect("sqlite::memory:").await.unwrap(); + let owner = "ef".repeat(32); + let selected = ensure_configured_community(&pool, "127.0.0.1:43001") + .await + .unwrap(); + let stale = ensure_configured_community(&pool, "127.0.0.1:43002") + .await + .unwrap(); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES (?1, ?2, 'owner')", + ) + .bind(selected.id.as_uuid().to_string()) + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + + let rebound = rebind_single_node_community_host(&pool, "127.0.0.1:43002", &owner) + .await + .unwrap() + .unwrap(); + assert_eq!(rebound.id, selected.id); + assert_eq!( + lookup_community_by_host(&pool, "127.0.0.1:43002") + .await + .unwrap() + .unwrap() + .id, + selected.id + ); + assert_eq!( + lookup_community_by_host(&pool, "127.0.0.1:43001") + .await + .unwrap() + .unwrap() + .id, + stale.id + ); + } + #[tokio::test] async fn upgrades_phase_1_core_schema_before_dm_use() { let path = std::env::temp_dir().join(format!("buzz-db-upgrade-{}.sqlite", Uuid::new_v4())); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 6cdd032bf..fea47726a 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1232,7 +1232,14 @@ async fn run_single_node(config: Config, tracer_init: telemetry::TracerInit) -> "Cannot derive community host from BUZZ_RELAY_URL" )); } - let community = db.ensure_configured_community(&host).await?.id; + let community = if let Some(owner) = config.relay_owner_pubkey.as_deref() { + match db.rebind_single_node_community_host(&host, owner).await? { + Some(record) => record.id, + None => db.ensure_configured_community(&host).await?.id, + } + } else { + db.ensure_configured_community(&host).await?.id + }; if let Some(owner) = config.relay_owner_pubkey.as_deref() { db.bootstrap_owner(community, owner).await?; } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc939..db9562e8d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -18,6 +18,46 @@ use crate::{ util::now_iso, }; +pub(super) fn backfill_missing_agent_auth_tags( + app: &AppHandle, + state: &AppState, +) -> Result { + let owner_keys = state.signing_keys()?; + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut changed = 0; + for record in &mut records { + if record.auth_tag.is_some() + || record + .pubkey + .eq_ignore_ascii_case(&owner_keys.public_key().to_hex()) + { + continue; + } + let agent = nostr::PublicKey::from_hex(&record.pubkey) + .map_err(|e| format!("invalid managed agent pubkey {}: {e}", record.pubkey))?; + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &agent, "").map_err(|e| { + format!( + "failed to compute NIP-OA auth tag for {}: {e}", + record.pubkey + ) + })?, + ); + record.updated_at = now_iso(); + changed += 1; + } + if changed > 0 { + save_managed_agents(app, &records)?; + } + Ok(changed) +} + /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index f7b022d04..32f041cda 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -145,7 +145,8 @@ pub async fn apply_workspace( None => None, }; - if crate::local_relay::is_local_relay_url(&relay_url) { + let is_local_workspace = crate::local_relay::is_local_relay_url(&relay_url); + if is_local_workspace { // Only the UI-created local sentinel can launch the bundled relay. // An arbitrary loopback URL remains a normal remote community. let keys = parsed_keys.clone().unwrap_or(state.signing_keys()?); @@ -228,6 +229,12 @@ pub async fn apply_workspace( } try_regenerate_nest(&app); + if is_local_workspace { + let changed = crate::commands::agents::backfill_missing_agent_auth_tags(&app, &state)?; + if changed > 0 { + eprintln!("buzz-desktop: backfilled NIP-OA auth tags for {changed} local agent(s)"); + } + } Ok::<(), String>(()) }) diff --git a/desktop/src-tauri/src/local_relay.rs b/desktop/src-tauri/src/local_relay.rs index 8dd8bcff0..8e8ecc6a4 100644 --- a/desktop/src-tauri/src/local_relay.rs +++ b/desktop/src-tauri/src/local_relay.rs @@ -53,6 +53,7 @@ impl LocalRelayConfig { ("BUZZ_LOCAL_DB", sqlite_url(&db_path)), ("BUZZ_LOCAL_MEDIA_DIR", media_dir.display().to_string()), ("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true".to_string()), + ("BUZZ_ALLOW_NIP_OA_AUTH", "true".to_string()), ("RELAY_OWNER_PUBKEY", self.owner_pubkey.clone()), ("BUZZ_RELAY_PRIVATE_KEY", self.relay_private_key.clone()), ] @@ -394,6 +395,7 @@ mod tests { env.get("BUZZ_REQUIRE_RELAY_MEMBERSHIP"), Some(&"true".to_string()) ); + assert_eq!(env.get("BUZZ_ALLOW_NIP_OA_AUTH"), Some(&"true".to_string())); assert_eq!(env.get("RELAY_OWNER_PUBKEY"), Some(&"owner".to_string())); assert_eq!( env.get("BUZZ_RELAY_PRIVATE_KEY"),