Fix local relay auth and community persistence

Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
2026-08-10 12:43:26 -04:00
committed by Brother Darryl
parent 007d43ee1a
commit ed5633c6cf
6 changed files with 262 additions and 2 deletions
+21
View File
@@ -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<Option<CommunityRecord>> {
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
+183
View File
@@ -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<Option<CommunityRecord>> {
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()));
+8 -1
View File
@@ -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?;
}
+40
View File
@@ -18,6 +18,46 @@ use crate::{
util::now_iso,
};
pub(super) fn backfill_missing_agent_auth_tags(
app: &AppHandle,
state: &AppState,
) -> Result<usize, String> {
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<String, String> {
+8 -1
View File
@@ -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>(())
})
+2
View File
@@ -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"),