mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay): scope relay membership to its community
NIP-43 admission confinement (Max review #3 on PR #1321). The `relay_members` table is keyed `(community_id, pubkey)`, but every DB access keyed on `pubkey` alone. In closed mode a pubkey admitted to community A was therefore admitted to community B — the exact M9 mutation #1285 targets. Request-path scoping: - thread `CommunityId` through all 9 `buzz-db::relay_members` functions and their `Db` wrappers; every query/insert/list/bootstrap now binds `community_id` (PK `ON CONFLICT (community_id, pubkey)`, WHERE clauses carry community). - `check_/enforce_relay_membership` take the server-resolved community; pass `tenant.community()` at every entrypoint that already binds a tenant: bridge (events/query/count), media upload, git transport, audio handle, WS auth (`conn.tenant`), mesh `require_mesh_member` (connect + status), relay-admin events, leave-request ingest, identity-archive consent, NIP-43 list publish, and the buzz-admin CLI (via `resolve_admin_tenant`). Startup seeding (the bootstrap half of the same fix): membership backfill and owner bootstrap previously ran with no community. They now run against the deployment's own community, seeded via `ensure_configured_community` under the *same* normalized host that live request resolution derives (`relay_url_authority` → `normalize_host`, now `pub`), so the bootstrapped owner lands in exactly the community requests for this host resolve to. An unparseable `relay_url` fails fast when membership is enforced rather than seeding an unreachable empty-host community. Regression (Postgres-backed, `#[ignore]`): - `membership_is_confined_to_its_community`: A admits a pubkey, B does not — `is_/get_/list_relay_members` confine it to A. - `owner_bootstrap_is_confined_to_its_community`: owner bootstrapped in A is not a member of B. cargo test -p buzz-db -p buzz-relay -p buzz-admin green; both new tests pass against live Postgres; clippy clean on all three crates. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
6846a8f10c
commit
1151eca83d
@@ -145,7 +145,11 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
||||
|
||||
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
||||
|
||||
match db.add_relay_member(&pubkey_hex, &role, None).await {
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
match db
|
||||
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => println!("added {pubkey_hex} as {role}"),
|
||||
Ok(false) => println!("already a member: {pubkey_hex} (no change)"),
|
||||
Err(e) => {
|
||||
@@ -154,7 +158,6 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
||||
eprintln!("warning: member added to DB but list publish failed: {e}");
|
||||
}
|
||||
@@ -180,11 +183,13 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
|
||||
|
||||
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
use buzz_db::relay_members::RemoveResult;
|
||||
let result = if let Some(ref role) = role_filter {
|
||||
db.remove_relay_member_if_role(&pubkey_hex, role).await
|
||||
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
|
||||
.await
|
||||
} else {
|
||||
db.remove_relay_member(&pubkey_hex).await
|
||||
db.remove_relay_member(tenant.community(), &pubkey_hex).await
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -211,7 +216,6 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
|
||||
}
|
||||
}
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
||||
eprintln!("warning: member removed from DB but list publish failed: {e}");
|
||||
}
|
||||
@@ -221,7 +225,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
|
||||
|
||||
async fn cmd_list_members() -> Result<i32> {
|
||||
let db = connect_db().await?;
|
||||
let members = db.list_relay_members().await?;
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
let members = db.list_relay_members(tenant.community()).await?;
|
||||
|
||||
if members.is_empty() {
|
||||
println!("(no relay members)");
|
||||
@@ -303,7 +308,7 @@ async fn publish_membership_list_with_bump(
|
||||
None => now,
|
||||
};
|
||||
|
||||
let members = db.list_relay_members().await?;
|
||||
let members = db.list_relay_members(tenant.community()).await?;
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
||||
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
|
||||
|
||||
+41
-26
@@ -1799,71 +1799,86 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
|
||||
relay_members::is_relay_member(&self.pool, pubkey).await
|
||||
/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
|
||||
pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result<bool> {
|
||||
relay_members::is_relay_member(&self.pool, community, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns the relay member record for `pubkey`, or `None` if not found.
|
||||
/// Returns the relay member record for `pubkey` in `community`, or `None` if not found.
|
||||
pub async fn get_relay_member(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<relay_members::RelayMember>> {
|
||||
relay_members::get_relay_member(&self.pool, pubkey).await
|
||||
relay_members::get_relay_member(&self.pool, community, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns all relay members ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(&self) -> Result<Vec<relay_members::RelayMember>> {
|
||||
relay_members::list_relay_members(&self.pool).await
|
||||
/// Returns all relay members of `community` ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
) -> Result<Vec<relay_members::RelayMember>> {
|
||||
relay_members::list_relay_members(&self.pool, community).await
|
||||
}
|
||||
|
||||
/// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent).
|
||||
/// Adds a new relay member.
|
||||
/// Adds a new relay member to `community`.
|
||||
///
|
||||
/// Returns `true` if the row was actually inserted, `false` if the pubkey
|
||||
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
|
||||
/// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`).
|
||||
pub async fn add_relay_member(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
added_by: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await
|
||||
relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
|
||||
}
|
||||
|
||||
/// Removes a relay member atomically, refusing to delete the owner.
|
||||
pub async fn remove_relay_member(&self, pubkey: &str) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member(&self.pool, pubkey).await
|
||||
/// Removes a relay member from `community` atomically, refusing to delete the owner.
|
||||
pub async fn remove_relay_member(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member(&self.pool, community, pubkey).await
|
||||
}
|
||||
|
||||
/// Removes a relay member only if their current role matches `expected_role`.
|
||||
/// Removes a relay member from `community` only if their current role matches `expected_role`.
|
||||
///
|
||||
/// Atomic conditional delete — eliminates the TOCTOU race between a
|
||||
/// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`].
|
||||
pub async fn remove_relay_member_if_role(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
expected_role: &str,
|
||||
) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await
|
||||
relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result<bool> {
|
||||
relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await
|
||||
/// Updates the role of an existing relay member in `community`. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
new_role: &str,
|
||||
) -> Result<bool> {
|
||||
relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await
|
||||
}
|
||||
|
||||
/// Ensures the owner pubkey exists with role `"owner"`. Called at startup.
|
||||
pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> {
|
||||
relay_members::bootstrap_owner(&self.pool, owner_pubkey).await
|
||||
/// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup.
|
||||
pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> {
|
||||
relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await
|
||||
}
|
||||
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`.
|
||||
///
|
||||
/// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows
|
||||
/// inserted, or 0 if the `pubkey_allowlist` table doesn't exist.
|
||||
pub async fn backfill_from_allowlist(&self) -> Result<u64> {
|
||||
relay_members::backfill_from_allowlist(&self.pool).await
|
||||
pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result<u64> {
|
||||
relay_members::backfill_from_allowlist(&self.pool, community).await
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
//! Relay-level membership persistence (NIP-43).
|
||||
//!
|
||||
//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata.
|
||||
//! All pubkey values are 64-char lowercase hex strings.
|
||||
//! The `relay_members` table is community-scoped: its primary key is
|
||||
//! `(community_id, pubkey)`. Every read, write, and list is bound to a single
|
||||
//! `community_id` so that admitting a pubkey to community A never admits it to
|
||||
//! community B (NIP-43 admission confinement). `pubkey` values are 64-char
|
||||
//! lowercase hex strings.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::CommunityId;
|
||||
|
||||
/// A single relay member record.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -23,21 +27,31 @@ pub struct RelayMember {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
|
||||
/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
|
||||
pub async fn is_relay_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Returns the relay member record for `pubkey`, or `None` if not found.
|
||||
pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<RelayMember>> {
|
||||
/// Returns the relay member record for `pubkey` in `community`, or `None`.
|
||||
pub async fn get_relay_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<RelayMember>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT pubkey, role, added_by, created_at, updated_at \
|
||||
FROM relay_members WHERE pubkey = $1",
|
||||
FROM relay_members WHERE community_id = $1 AND pubkey = $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -55,12 +69,16 @@ pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<Rela
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Returns all relay members ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
|
||||
/// Returns all relay members of `community` ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
) -> Result<Vec<RelayMember>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT pubkey, role, added_by, created_at, updated_at \
|
||||
FROM relay_members ORDER BY created_at ASC",
|
||||
FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
@@ -78,20 +96,23 @@ pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Adds a new relay member.
|
||||
/// Adds a new relay member to `community`.
|
||||
///
|
||||
/// Returns `true` if the row was actually inserted, `false` if the pubkey
|
||||
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
|
||||
/// already existed in this community (idempotent — `ON CONFLICT DO NOTHING` on
|
||||
/// the `(community_id, pubkey)` primary key).
|
||||
pub async fn add_relay_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
added_by: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING",
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(role)
|
||||
.bind(added_by)
|
||||
@@ -118,11 +139,19 @@ pub enum RemoveResult {
|
||||
/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the
|
||||
/// owner-protection check and the deletion are one atomic operation —
|
||||
/// no TOCTOU race between a separate read and delete.
|
||||
pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveResult> {
|
||||
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'")
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
pub async fn remove_relay_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<RemoveResult> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM relay_members \
|
||||
WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(RemoveResult::Removed);
|
||||
@@ -130,7 +159,8 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveRe
|
||||
|
||||
// rows_affected == 0: either not found or is owner. One cheap read to
|
||||
// distinguish the two cases so callers can return the right error message.
|
||||
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
|
||||
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -157,14 +187,18 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveRe
|
||||
/// this delete).
|
||||
pub async fn remove_relay_member_if_role(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
expected_role: &str,
|
||||
) -> Result<RemoveResult> {
|
||||
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2")
|
||||
.bind(pubkey)
|
||||
.bind(expected_role)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(expected_role)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(RemoveResult::Removed);
|
||||
@@ -172,7 +206,8 @@ pub async fn remove_relay_member_if_role(
|
||||
|
||||
// rows_affected == 0: either not found or role changed. One cheap read to
|
||||
// distinguish the cases so callers can return the right error message.
|
||||
let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1")
|
||||
let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -193,42 +228,58 @@ pub async fn remove_relay_member_if_role(
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result<bool> {
|
||||
/// Updates the role of an existing relay member in `community`. Returns `true`
|
||||
/// if updated.
|
||||
pub async fn update_relay_member_role(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
new_role: &str,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'",
|
||||
"UPDATE relay_members SET role = $1, updated_at = now() \
|
||||
WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'",
|
||||
)
|
||||
.bind(new_role)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes
|
||||
/// any other owners to `"admin"`. This handles owner rotation: if
|
||||
/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted.
|
||||
/// Ensures the configured owner pubkey holds the `"owner"` role *in
|
||||
/// `community`*, and demotes any other owners in that community to `"admin"`.
|
||||
/// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner
|
||||
/// is automatically demoted. Scoped to one community — an owner of community A
|
||||
/// is never bootstrapped into community B.
|
||||
///
|
||||
/// Runs in a single transaction. Safe to call at every startup — idempotent.
|
||||
pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
|
||||
pub async fn bootstrap_owner(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<()> {
|
||||
let pubkey = owner_pubkey.to_ascii_lowercase();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 1. Upsert the configured owner.
|
||||
// 1. Upsert the configured owner for this community.
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by) \
|
||||
VALUES ($1, 'owner', NULL) \
|
||||
ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
|
||||
VALUES ($1, $2, 'owner', NULL) \
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(&pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 2. Demote any other owners to admin.
|
||||
// 2. Demote any other owners in this community to admin.
|
||||
sqlx::query(
|
||||
"UPDATE relay_members SET role = 'admin', updated_at = now() \
|
||||
WHERE role = 'owner' AND pubkey <> $1",
|
||||
WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(&pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -237,16 +288,18 @@ pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members` for
|
||||
/// `community` (the deployment's default community).
|
||||
///
|
||||
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members.
|
||||
/// Returns the number of rows inserted, or 0 if:
|
||||
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of
|
||||
/// `community`. Returns the number of rows inserted, or 0 if:
|
||||
/// - the `pubkey_allowlist` table doesn't exist, or
|
||||
/// - `relay_members` already has rows (migration ran in a prior startup).
|
||||
/// - `relay_members` already has rows for this community (migration ran in a
|
||||
/// prior startup).
|
||||
///
|
||||
/// The empty-table guard prevents re-adding members that were intentionally
|
||||
/// removed by an admin after the initial backfill.
|
||||
pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
|
||||
pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result<u64> {
|
||||
// Check if pubkey_allowlist table exists.
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
|
||||
@@ -259,25 +312,155 @@ pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Only backfill if relay_members is empty — once the table has rows
|
||||
// (from a previous backfill or manual admin commands), we must not
|
||||
// Only backfill if this community's relay_members is empty — once it has
|
||||
// rows (from a previous backfill or manual admin commands), we must not
|
||||
// re-add members that were intentionally removed.
|
||||
let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let has_members: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if has_members {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by, created_at) \
|
||||
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \
|
||||
SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \
|
||||
FROM pubkey_allowlist \
|
||||
ON CONFLICT (pubkey) DO NOTHING",
|
||||
ON CONFLICT (community_id, pubkey) DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> CommunityId {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("relay-members-test-{}.example", id.simple());
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(host)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert test community");
|
||||
CommunityId::from_uuid(id)
|
||||
}
|
||||
|
||||
/// NIP-43 admission confinement: a pubkey admitted to community A is *not*
|
||||
/// admitted to community B. This is the exact mutation #1285 targets — a
|
||||
/// `WHERE pubkey = $1` membership check (no community predicate) would let an
|
||||
/// A-member authenticate against B. We add the pubkey only to A and assert
|
||||
/// every read path (`is_relay_member`, `get_relay_member`, `list_relay_members`)
|
||||
/// confines it to A.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn membership_is_confined_to_its_community() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
// 64-char lowercase hex, unique per run so reruns don't collide.
|
||||
let pubkey = format!("{:064x}", Uuid::new_v4().as_u128());
|
||||
|
||||
let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None)
|
||||
.await
|
||||
.expect("add member to community A");
|
||||
assert!(inserted, "first insert into A should report inserted");
|
||||
|
||||
// is_relay_member: member of A, NOT of B.
|
||||
assert!(
|
||||
is_relay_member(&pool, community_a, &pubkey)
|
||||
.await
|
||||
.expect("is_relay_member A"),
|
||||
"pubkey must be a member of community A"
|
||||
);
|
||||
assert!(
|
||||
!is_relay_member(&pool, community_b, &pubkey)
|
||||
.await
|
||||
.expect("is_relay_member B"),
|
||||
"pubkey admitted to A must NOT be a member of B (admission confinement)"
|
||||
);
|
||||
|
||||
// get_relay_member (used by the NIP-OA owner check + admin role lookups):
|
||||
// resolves in A, absent in B.
|
||||
assert!(
|
||||
get_relay_member(&pool, community_a, &pubkey)
|
||||
.await
|
||||
.expect("get_relay_member A")
|
||||
.is_some(),
|
||||
"get_relay_member must resolve in community A"
|
||||
);
|
||||
assert!(
|
||||
get_relay_member(&pool, community_b, &pubkey)
|
||||
.await
|
||||
.expect("get_relay_member B")
|
||||
.is_none(),
|
||||
"get_relay_member must not resolve the A pubkey in community B"
|
||||
);
|
||||
|
||||
// list_relay_members: B's list never contains A's member.
|
||||
let list_a = list_relay_members(&pool, community_a)
|
||||
.await
|
||||
.expect("list A");
|
||||
assert!(
|
||||
list_a.iter().any(|m| m.pubkey == pubkey),
|
||||
"community A list must contain the admitted pubkey"
|
||||
);
|
||||
let list_b = list_relay_members(&pool, community_b)
|
||||
.await
|
||||
.expect("list B");
|
||||
assert!(
|
||||
list_b.iter().all(|m| m.pubkey != pubkey),
|
||||
"community B list must not contain A's member"
|
||||
);
|
||||
}
|
||||
|
||||
/// Owner bootstrap is community-scoped: bootstrapping the owner in A does not
|
||||
/// make that pubkey an owner (or member) of B. Guards against a global
|
||||
/// `INSERT ... (pubkey, role)` bootstrap leaking the owner across tenants.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn owner_bootstrap_is_confined_to_its_community() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
let owner = format!("{:064x}", Uuid::new_v4().as_u128());
|
||||
|
||||
bootstrap_owner(&pool, community_a, &owner)
|
||||
.await
|
||||
.expect("bootstrap owner in A");
|
||||
|
||||
let in_a = get_relay_member(&pool, community_a, &owner)
|
||||
.await
|
||||
.expect("get owner A")
|
||||
.expect("owner exists in A");
|
||||
assert_eq!(in_a.role, "owner", "bootstrapped pubkey must be owner in A");
|
||||
|
||||
assert!(
|
||||
!is_relay_member(&pool, community_b, &owner)
|
||||
.await
|
||||
.expect("is_relay_member B"),
|
||||
"owner bootstrapped in A must NOT be a member of B"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,13 @@ pub async fn submit_event(
|
||||
|
||||
// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
super::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
tenant.community(),
|
||||
&pubkey_bytes,
|
||||
auth_tag,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event: nostr::Event = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?;
|
||||
@@ -349,7 +355,13 @@ pub async fn query_events(
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
super::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
tenant.community(),
|
||||
&pubkey_bytes,
|
||||
auth_tag,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Two-pass parse: preserve raw JSON for custom extension fields (before_id,
|
||||
// depth_limit, feed_types) that nostr::Filter silently drops.
|
||||
@@ -624,7 +636,13 @@ pub async fn count_events(
|
||||
let pubkey_bytes = pubkey.to_bytes().to_vec();
|
||||
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
super::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
tenant.community(),
|
||||
&pubkey_bytes,
|
||||
auth_tag,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let filters: Vec<nostr::Filter> = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
|
||||
|
||||
@@ -182,9 +182,14 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
.headers
|
||||
.get("x-auth-tag")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
if crate::api::relay_members::enforce_relay_membership(state, pubkey.as_bytes(), auth_tag)
|
||||
.await
|
||||
.is_err()
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
state,
|
||||
tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
|
||||
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
|
||||
|
||||
@@ -112,6 +112,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
crate::api::relay_members::enforce_relay_membership(
|
||||
state,
|
||||
tenant.community(),
|
||||
auth_event.pubkey.as_bytes(),
|
||||
auth_tag,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
|
||||
/// `git/transport.rs`, and `audio/handler.rs`.
|
||||
pub mod relay_members {
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use buzz_core::tenant::CommunityId;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -50,8 +51,12 @@ pub mod relay_members {
|
||||
}
|
||||
|
||||
/// Check relay membership without committing to an HTTP response shape.
|
||||
///
|
||||
/// `community` is the server-resolved tenant of the request; membership is
|
||||
/// scoped to it so admitting a pubkey to community A never admits it to B.
|
||||
pub async fn check_relay_membership(
|
||||
state: &AppState,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<MembershipDecision, String> {
|
||||
@@ -62,7 +67,7 @@ pub mod relay_members {
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
let is_member = state
|
||||
.db
|
||||
.is_relay_member(&pubkey_hex)
|
||||
.is_relay_member(community, &pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| format!("relay membership check failed: {e}"))?;
|
||||
if is_member {
|
||||
@@ -77,8 +82,11 @@ pub mod relay_members {
|
||||
match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
|
||||
Ok(owner_pubkey) => {
|
||||
let owner_hex = owner_pubkey.to_hex();
|
||||
let owner_is_member =
|
||||
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
|
||||
let owner_is_member = state
|
||||
.db
|
||||
.is_relay_member(community, &owner_hex)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("relay membership check (owner) failed: {e}")
|
||||
})?;
|
||||
if owner_is_member {
|
||||
@@ -113,10 +121,11 @@ pub mod relay_members {
|
||||
/// no NIP-OA tag is present/applicable (open relay without auth tag).
|
||||
pub async fn enforce_relay_membership(
|
||||
state: &AppState,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
|
||||
match check_relay_membership(state, pubkey_bytes, auth_tag_header).await {
|
||||
match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await {
|
||||
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
|
||||
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),
|
||||
Ok(MembershipDecision::Denied) => Err((
|
||||
|
||||
@@ -182,6 +182,7 @@ async fn handle_audio_connection(
|
||||
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
|
||||
@@ -113,6 +113,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
// Relay membership gate — uses the shared helper with NIP-OA fallback.
|
||||
let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
conn.tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
|
||||
@@ -238,7 +238,7 @@ async fn determine_consent_path(
|
||||
|
||||
let actor_member = state
|
||||
.db
|
||||
.get_relay_member(actor_hex)
|
||||
.get_relay_member(community_id, actor_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
let actor_role = actor_member.as_ref().map(|m| m.role.as_str()).unwrap_or("");
|
||||
|
||||
@@ -1478,7 +1478,7 @@ async fn ingest_event_inner(
|
||||
// remove_relay_member handles both the NotFound and IsOwner cases atomically.
|
||||
let remove_result = state
|
||||
.db
|
||||
.remove_relay_member(&sender_hex)
|
||||
.remove_relay_member(tenant.community(), &sender_hex)
|
||||
.await
|
||||
.map_err(|e| IngestError::Internal(format!("database error: {e}")))?;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use buzz_core::event::StoredEvent;
|
||||
use buzz_core::kind::{
|
||||
event_kind_u32, KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT,
|
||||
};
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use buzz_core::tenant::{CommunityId, TenantContext};
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
|
||||
use crate::api::relay_members::{check_relay_membership, MembershipDecision};
|
||||
@@ -234,11 +234,11 @@ pub async fn handle_connect_request(
|
||||
// BUZZ_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we
|
||||
// re-check the requester here with no auth tag (which makes ViaOwner
|
||||
// unreachable — only Member/OpenRelay/Denied) to match the target check.
|
||||
require_mesh_member(state, requester_pubkey_hex)
|
||||
require_mesh_member(state, tenant.community(), requester_pubkey_hex)
|
||||
.await
|
||||
.map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?;
|
||||
|
||||
require_mesh_member(state, &target_hex)
|
||||
require_mesh_member(state, tenant.community(), &target_hex)
|
||||
.await
|
||||
.map_err(|_| "restricted: target is not a relay member".to_string())?;
|
||||
|
||||
@@ -274,9 +274,13 @@ pub async fn handle_connect_request(
|
||||
/// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit;
|
||||
/// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error)
|
||||
/// FAILS CLOSED. Used symmetrically for requester and target.
|
||||
async fn require_mesh_member(state: &Arc<AppState>, pubkey_hex: &str) -> Result<(), ()> {
|
||||
async fn require_mesh_member(
|
||||
state: &Arc<AppState>,
|
||||
community: CommunityId,
|
||||
pubkey_hex: &str,
|
||||
) -> Result<(), ()> {
|
||||
let bytes = hex::decode(pubkey_hex).map_err(|_| ())?;
|
||||
match check_relay_membership(state, &bytes, None).await {
|
||||
match check_relay_membership(state, community, &bytes, None).await {
|
||||
Ok(d) if membership_admits_mesh(&d) => Ok(()),
|
||||
Ok(_) => Err(()),
|
||||
Err(e) => {
|
||||
@@ -355,7 +359,7 @@ pub async fn handle_status_report(
|
||||
// pubkey that the connect path (which denies ViaOwner) then refuses — broken
|
||||
// discovery. So gate the reporter the same way: direct members only, fail
|
||||
// closed. Keeps all three desktop-facing mesh kinds consistent on delegation.
|
||||
require_mesh_member(state, reporter_pubkey_hex)
|
||||
require_mesh_member(state, tenant.community(), reporter_pubkey_hex)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"restricted: delegated identities cannot report mesh status in v1".to_string()
|
||||
|
||||
@@ -94,7 +94,7 @@ pub async fn handle_relay_admin_event(
|
||||
|
||||
let sender_member = state
|
||||
.db
|
||||
.get_relay_member(&sender_hex)
|
||||
.get_relay_member(tenant.community(), &sender_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
@@ -130,7 +130,7 @@ pub async fn handle_relay_admin_event(
|
||||
// to change an existing member's role.
|
||||
let was_inserted = state
|
||||
.db
|
||||
.add_relay_member(&target_hex, &role, Some(&sender_hex))
|
||||
.add_relay_member(tenant.community(), &target_hex, &role, Some(&sender_hex))
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
@@ -174,14 +174,14 @@ pub async fn handle_relay_admin_event(
|
||||
let remove_result = if sender_role == "admin" {
|
||||
state
|
||||
.db
|
||||
.remove_relay_member_if_role(&target_hex, "member")
|
||||
.remove_relay_member_if_role(tenant.community(), &target_hex, "member")
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?
|
||||
} else {
|
||||
// Owner path — atomic delete that refuses to remove other owners.
|
||||
state
|
||||
.db
|
||||
.remove_relay_member(&target_hex)
|
||||
.remove_relay_member(tenant.community(), &target_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?
|
||||
};
|
||||
@@ -240,7 +240,7 @@ pub async fn handle_relay_admin_event(
|
||||
|
||||
let updated = state
|
||||
.db
|
||||
.update_relay_member_role(&target_hex, &new_role)
|
||||
.update_relay_member_role(tenant.community(), &target_hex, &new_role)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
@@ -248,7 +248,7 @@ pub async fn handle_relay_admin_event(
|
||||
// Distinguish "owner (protected)" from "doesn't exist"
|
||||
let exists = state
|
||||
.db
|
||||
.get_relay_member(&target_hex)
|
||||
.get_relay_member(tenant.community(), &target_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
return Err(if exists.is_some() {
|
||||
|
||||
@@ -2336,7 +2336,7 @@ pub async fn publish_nip43_membership_list(
|
||||
tenant: &TenantContext,
|
||||
state: &Arc<AppState>,
|
||||
) -> anyhow::Result<()> {
|
||||
let members = state.db.list_relay_members().await?;
|
||||
let members = state.db.list_relay_members(tenant.community()).await?;
|
||||
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
||||
|
||||
@@ -104,30 +104,80 @@ async fn main() -> anyhow::Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// NIP-43 / multi-tenant: seed the deployment's *own* community before any
|
||||
// membership backfill or owner bootstrap, so those writes are scoped to a
|
||||
// real `(community_id, pubkey)` and not a global pubkey. The host is derived
|
||||
// from `relay_url` with the *same* normalization request resolution uses
|
||||
// (`relay_url_authority` → `normalize_host`), so the bootstrapped owner lands
|
||||
// in exactly the community that live requests for this host will resolve to.
|
||||
//
|
||||
// `ensure_configured_community` is idempotent (`ON CONFLICT (host)`), so this
|
||||
// is safe to run every startup. An empty authority (unparseable `relay_url`)
|
||||
// is a misconfiguration — fail fast when membership is enforced rather than
|
||||
// seeding an empty-host community that no request can ever resolve to.
|
||||
let deployment_community = {
|
||||
let host = buzz_relay::tenant::relay_url_authority(&config.relay_url);
|
||||
if host.is_empty() {
|
||||
if config.require_relay_membership {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot derive a community host from BUZZ_RELAY_URL ({:?}); a resolvable host is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true",
|
||||
config.relay_url
|
||||
));
|
||||
}
|
||||
error!(
|
||||
relay_url = %config.relay_url,
|
||||
"Could not derive a community host from relay_url; skipping membership backfill/bootstrap (non-fatal, membership not required)"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
match db.ensure_configured_community(&host).await {
|
||||
Ok(record) => {
|
||||
info!(host = %record.host, community = %record.id, "Deployment community ensured");
|
||||
Some(record.id)
|
||||
}
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
error!("Fatal: failed to ensure deployment community with membership enforcement enabled: {e}");
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to ensure deployment community (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
|
||||
));
|
||||
}
|
||||
error!("Failed to ensure deployment community (non-fatal, membership not required): {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// NIP-43: migrate any existing pubkey_allowlist entries to relay_members.
|
||||
// Idempotent — safe to run every startup. Must run before bootstrap_owner
|
||||
// so that existing allowlist users become relay members before the owner
|
||||
// is promoted (otherwise enabling membership locks everyone out).
|
||||
match db.backfill_from_allowlist().await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
error!(
|
||||
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
|
||||
));
|
||||
} else {
|
||||
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
|
||||
if let Some(community) = deployment_community {
|
||||
match db.backfill_from_allowlist(community).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
error!(
|
||||
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
|
||||
));
|
||||
} else {
|
||||
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-43: ensure the configured relay owner always holds the owner role.
|
||||
if let Some(ref owner_pubkey) = config.relay_owner_pubkey {
|
||||
match db.bootstrap_owner(owner_pubkey).await {
|
||||
// NIP-43: ensure the configured relay owner always holds the owner role
|
||||
// within the deployment community.
|
||||
if let (Some(community), Some(owner_pubkey)) =
|
||||
(deployment_community, config.relay_owner_pubkey.as_ref())
|
||||
{
|
||||
match db.bootstrap_owner(community, owner_pubkey).await {
|
||||
Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"),
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
|
||||
@@ -111,7 +111,13 @@ pub async fn bind_deployment_community<R: HostResolver>(
|
||||
/// Extract the relay URL authority in the same normalized shape as request
|
||||
/// `Host` headers and `communities.host`: host plus an explicit non-default
|
||||
/// port, if present.
|
||||
fn relay_url_authority(relay_url: &str) -> String {
|
||||
///
|
||||
/// `pub` so startup ([`crate::main`], a separate binary crate) can seed the
|
||||
/// deployment's own community under the *same* normalized host that live request
|
||||
/// resolution ([`bind_community`]) will derive — the two must agree or the
|
||||
/// bootstrapped owner lands in a community no request ever resolves to. Returns
|
||||
/// the empty string when `relay_url` has no parseable host.
|
||||
pub fn relay_url_authority(relay_url: &str) -> String {
|
||||
let Ok(url) = url::Url::parse(relay_url) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user