mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(mesh): run the mesh unenforced on relays that do not enforce membership
Mesh admission is seeded from the relay's NIP-43 membership snapshot (kind:13534), which is built purely from the `relay_members` table. On a relay running with `BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`, signing in never writes a `relay_members` row, so that snapshot lists at most whoever was added out of band. Ordinary participants are absent from it. Desktop treated that as an authoritative roster, so every node resolved an empty intersection, fell back to `trust_owners([self])`, and sat at "1 NODE" with an empty peer list forever. Two machines sharing compute on the same open relay could never see each other, through any number of restarts. Admission was only part of it: `availability_from_events` also required a snapshot and filtered status notes to `members.contains(reporter)`, so serve-target discovery was membership-gated too. Relaxing admission alone would still have left peers undiscoverable. Resolve the relay's membership mode from its NIP-11 document instead. The relay advertises NIP-43 only when membership is actually enforced, so the advertisement is a faithful signal. When it is absent, skip the snapshot requirement for routing and pass `trusted_owner_ids: None`, which the existing SDK path already maps to `TrustPolicy::Off` — stock MeshLLM behaviour, scoped by the relay-derived mesh topic. Membership emptiness deliberately does NOT drive this. On an enforcing relay a missing snapshot is a transient replication gap, and the existing code treats it as an error precisely so a blip cannot flap a live node down to self-only. Keying open-mesh off "no snapshot" would have turned that blip into a silent loss of admission enforcement. A failed NIP-11 probe therefore falls back to the enforcing behaviour: an unreachable relay can never relax admission. Closed-relay behaviour is unchanged, including the fail-closed error and the shrink hysteresis. `roster_reconcile_action` maps `Ok(None)` to `Keep`: a node already enforcing an allowlist keeps it, so a mid-flight NIP-11 change cannot de-restrict a running mesh. Freshness and owner-signed endpoint binding still gate routing on an open relay; only the membership intersection is lifted. Co-authored-by: Michael Neale <michael.neale@gmail.com> Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -176,9 +176,63 @@ fn advance_mesh_status_cursor(
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
/// Resolve whether a relay enforces NIP-43 relay membership, from its NIP-11
|
||||
/// document.
|
||||
///
|
||||
/// The relay advertises NIP-43 only when membership is actually enforced
|
||||
/// (`nip11_facts` in `buzz-relay`), so this is the one signal that separates
|
||||
/// "no roster because nobody is a direct member here" from "no roster because
|
||||
/// this read was incomplete". A probe failure is reported as `Err` and treated
|
||||
/// as enforcing by callers: an unreachable relay must never be able to relax
|
||||
/// admission.
|
||||
async fn probe_relay_mesh_mode(relay_url: &str) -> Result<mesh_llm::MeshRelayMode, String> {
|
||||
let http_url = relay::relay_http_base_url(relay_url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build NIP-11 client: {error}"))?;
|
||||
let document: serde_json::Value = client
|
||||
.get(&http_url)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("NIP-11 probe failed: {error}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("NIP-11 document was not JSON: {error}"))?;
|
||||
Ok(mesh_llm::relay_mesh_mode_from_nip11(&document))
|
||||
}
|
||||
|
||||
async fn query_mesh_discovery_events_at(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
query_mesh_discovery_events_for_mode(
|
||||
state,
|
||||
relay_url,
|
||||
resolved_relay_mesh_mode(relay_url).await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Probe the relay's membership mode, falling back to the enforcing behaviour.
|
||||
///
|
||||
/// A failed probe must not open the mesh up: an unreachable or malformed NIP-11
|
||||
/// document leaves admission exactly as strict as it is today.
|
||||
async fn resolved_relay_mesh_mode(relay_url: &str) -> mesh_llm::MeshRelayMode {
|
||||
match probe_relay_mesh_mode(relay_url).await {
|
||||
Ok(mode) => mode,
|
||||
Err(error) => {
|
||||
eprintln!("buzz-mesh: NIP-11 probe failed; assuming membership is enforced: {error}");
|
||||
mesh_llm::MeshRelayMode::ClosedMembershipEnforced
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_mesh_discovery_events_for_mode(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
mode: mesh_llm::MeshRelayMode,
|
||||
) -> Result<Vec<nostr::Event>, String> {
|
||||
let api_base_url = relay::relay_http_base_url(relay_url);
|
||||
let mut events =
|
||||
@@ -193,13 +247,24 @@ async fn query_mesh_discovery_events_at(
|
||||
// means the query is incomplete: surface it as an error so the
|
||||
// reconcile loop keeps the current allowlist instead of flapping the
|
||||
// node down to self-only on a successful-but-empty response.
|
||||
if !mesh_llm::has_membership_snapshot(&events) {
|
||||
return Err("relay returned no membership snapshot".to_string());
|
||||
//
|
||||
// A relay that does not enforce membership never populates
|
||||
// `relay_members` from sign-in, so there is no roster to wait for and
|
||||
// no incomplete read to guard against. Fall through and read status
|
||||
// notes unfiltered so the caller can run the mesh unenforced.
|
||||
if !mode.is_open() {
|
||||
if !mesh_llm::has_membership_snapshot(&events) {
|
||||
return Err("relay returned no membership snapshot".to_string());
|
||||
}
|
||||
return Ok(events);
|
||||
}
|
||||
return Ok(events);
|
||||
}
|
||||
let mut status_filter = mesh_llm::mesh_status_filter();
|
||||
status_filter["authors"] = serde_json::json!(member_pubkeys);
|
||||
// On an enforcing relay, scope status reads to current members. Without a
|
||||
// roster there is nobody to scope to, so read every reporter's note.
|
||||
if !member_pubkeys.is_empty() {
|
||||
status_filter["authors"] = serde_json::json!(member_pubkeys);
|
||||
}
|
||||
let mut previous_cursor: Option<(u64, String)> = None;
|
||||
|
||||
loop {
|
||||
@@ -231,29 +296,39 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result<Vec<nostr::Even
|
||||
/// never be collapsed into "self-only", or a transient relay blip de-admits
|
||||
/// every other member. `reconcile_roster` relies on this to keep the current
|
||||
/// allowlist on error instead of restarting the node down to self-only.
|
||||
pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result<Vec<String>, String> {
|
||||
let events = query_mesh_discovery_events(state).await?;
|
||||
Ok(mesh_llm::owner_ids_from_events(&events))
|
||||
/// `None` means the relay does not enforce membership: there is no roster to
|
||||
/// intersect and the node must run unenforced (`TrustPolicy::Off`), exactly as
|
||||
/// stock MeshLLM does. `Some` carries an enforcing relay's roster.
|
||||
pub(crate) async fn resolve_trusted_owner_ids(
|
||||
state: &AppState,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
resolve_trusted_owner_ids_at(state, &relay::relay_ws_url_with_override(state)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_trusted_owner_ids_at(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let events = query_mesh_discovery_events_at(state, relay_url).await?;
|
||||
Ok(mesh_llm::owner_ids_from_events(&events))
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let mode = resolved_relay_mesh_mode(relay_url).await;
|
||||
let events = query_mesh_discovery_events_for_mode(state, relay_url, mode).await?;
|
||||
if mode.is_open() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(mesh_llm::owner_ids_from_events(&events)))
|
||||
}
|
||||
|
||||
/// Resolve the roster for an initial node *start*, failing closed to self-only
|
||||
/// (an empty roster) when the relay query fails. This is safe only at start:
|
||||
/// there is no established allowlist to preserve yet. The periodic
|
||||
/// `reconcile_roster` path must NOT use this — it has a live roster to keep.
|
||||
pub(crate) async fn resolve_trusted_owner_ids_or_self_only(state: &AppState) -> Vec<String> {
|
||||
pub(crate) async fn resolve_trusted_owner_ids_or_self_only(
|
||||
state: &AppState,
|
||||
) -> Option<Vec<String>> {
|
||||
match resolve_trusted_owner_ids(state).await {
|
||||
Ok(owners) => owners,
|
||||
Err(error) => {
|
||||
eprintln!("buzz-mesh: roster query failed; allowing only this node: {error}");
|
||||
Vec::new()
|
||||
Some(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,12 +366,13 @@ pub(crate) async fn resolve_buzz_mesh_join_targets_at(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
) -> Result<Vec<mesh_llm::MeshServeTarget>, String> {
|
||||
let events = query_mesh_discovery_events_at(state, relay_url).await?;
|
||||
let mode = resolved_relay_mesh_mode(relay_url).await;
|
||||
let events = query_mesh_discovery_events_for_mode(state, relay_url, mode).await?;
|
||||
let self_owner_id = mesh_llm::ensure_owner_identity()
|
||||
.map_err(|error| format!("failed to load mesh owner identity: {error}"))?
|
||||
.owner_id;
|
||||
Ok(buzz_mesh_join_targets(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
mesh_llm::availability_from_events_for_mode(events, mode).serve_targets,
|
||||
&self_owner_id,
|
||||
))
|
||||
}
|
||||
@@ -308,15 +384,19 @@ pub(crate) async fn resolve_buzz_mesh_join_targets_at(
|
||||
async fn resolve_buzz_mesh_startup_at(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
) -> (Vec<String>, Option<String>) {
|
||||
match query_mesh_discovery_events_at(state, relay_url).await {
|
||||
) -> (Option<Vec<String>>, Option<String>) {
|
||||
let mode = resolved_relay_mesh_mode(relay_url).await;
|
||||
match query_mesh_discovery_events_for_mode(state, relay_url, mode).await {
|
||||
Ok(events) => {
|
||||
let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events);
|
||||
// An open relay has no roster to enforce: `None` runs the node
|
||||
// unenforced instead of collapsing it to a self-only allowlist.
|
||||
let trusted_owner_ids =
|
||||
(!mode.is_open()).then(|| mesh_llm::owner_ids_from_events(&events));
|
||||
let join_token = mesh_llm::ensure_owner_identity()
|
||||
.ok()
|
||||
.and_then(|identity| {
|
||||
buzz_mesh_join_targets(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
mesh_llm::availability_from_events_for_mode(events, mode).serve_targets,
|
||||
&identity.owner_id,
|
||||
)
|
||||
.into_iter()
|
||||
@@ -332,7 +412,7 @@ async fn resolve_buzz_mesh_startup_at(
|
||||
eprintln!(
|
||||
"buzz-mesh: startup discovery failed; allowing only this node and starting isolated for now: {error}"
|
||||
);
|
||||
(Vec::new(), None)
|
||||
(Some(Vec::new()), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +453,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C
|
||||
join_token,
|
||||
mesh_name: Some(buzz_mesh_name_for_relay(&relay_url)),
|
||||
relay_url: Some(relay_url),
|
||||
trusted_owner_ids: Some(trusted_owner_ids),
|
||||
trusted_owner_ids,
|
||||
};
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(request)
|
||||
.await
|
||||
@@ -446,10 +526,17 @@ pub async fn mesh_start_node(
|
||||
|
||||
// Frontend requests never carry a roster. Resolve it and the bootstrap
|
||||
// endpoint from one snapshot so UI startup does not repeat relay probes.
|
||||
if request.trusted_owner_ids.is_none() || request.join_token.is_none() {
|
||||
let roster_unresolved = request.trusted_owner_ids.is_none();
|
||||
if roster_unresolved || request.join_token.is_none() {
|
||||
let (trusted_owner_ids, join_token) =
|
||||
resolve_buzz_mesh_startup_at(&state, &relay_url).await;
|
||||
request.trusted_owner_ids.get_or_insert(trusted_owner_ids);
|
||||
// Adopt the resolver's decision only when the caller supplied no
|
||||
// roster. `None` from the resolver is meaningful (open relay: run
|
||||
// unenforced), so this cannot use `get_or_insert`, which would treat
|
||||
// it as "nothing resolved" and leave the field untouched.
|
||||
if roster_unresolved {
|
||||
request.trusted_owner_ids = trusted_owner_ids;
|
||||
}
|
||||
if request.join_token.is_none() {
|
||||
request.join_token = join_token;
|
||||
}
|
||||
@@ -590,7 +677,7 @@ pub(crate) async fn ensure_client_node_for_model(
|
||||
join_token: Some(join_token.clone()),
|
||||
mesh_name: Some(buzz_mesh_name(state)),
|
||||
relay_url: Some(relay::relay_ws_url_with_override(state)),
|
||||
trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
|
||||
trusted_owner_ids: resolve_trusted_owner_ids_or_self_only(state).await,
|
||||
};
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if let Some(existing) = runtime.as_ref() {
|
||||
|
||||
@@ -234,14 +234,21 @@ fn roster_shrinks(current: &[String], fresh: &[String]) -> bool {
|
||||
///
|
||||
/// Rules:
|
||||
/// - query failed (`Err`) → `Keep` (never de-admit on a relay blip)
|
||||
/// - relay enforces no membership (`Ok(None)`) → `Keep` (see below)
|
||||
/// - resolved roster == current → `Keep` (no-op)
|
||||
/// - grows (only additions) → `RestartProcess` immediately (fast admission)
|
||||
/// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll)
|
||||
/// - shrinks/empties, confirmed → `RestartProcess` (same reduced roster twice)
|
||||
///
|
||||
/// `Ok(None)` means the relay does not enforce NIP-43 membership, so there is
|
||||
/// no roster to reconcile against. A node that is *already* enforcing an
|
||||
/// allowlist keeps it: relaxing a live node's admission on the strength of a
|
||||
/// NIP-11 read would let a relay downgrade (or a spoofed document) de-restrict
|
||||
/// a running mesh. The open-relay path is chosen at start, not mid-flight.
|
||||
fn roster_reconcile_action(
|
||||
current_owners: &[String],
|
||||
pending_shrink: Option<&[String]>,
|
||||
query: Result<Vec<String>, String>,
|
||||
query: Result<Option<Vec<String>>, String>,
|
||||
) -> RosterReconcileAction {
|
||||
let fresh = match query {
|
||||
Err(error) => {
|
||||
@@ -250,7 +257,13 @@ fn roster_reconcile_action(
|
||||
);
|
||||
return RosterReconcileAction::Keep;
|
||||
}
|
||||
Ok(fresh) => fresh,
|
||||
Ok(None) => {
|
||||
eprintln!(
|
||||
"buzz-mesh: relay does not enforce membership; keeping this node's current allowlist"
|
||||
);
|
||||
return RosterReconcileAction::Keep;
|
||||
}
|
||||
Ok(Some(fresh)) => fresh,
|
||||
};
|
||||
|
||||
if fresh == current_owners {
|
||||
@@ -561,7 +574,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unchanged_roster_is_a_noop() {
|
||||
let current = vec!["owner-a".to_string()];
|
||||
let action = roster_reconcile_action(¤t, None, Ok(vec!["owner-a".to_string()]));
|
||||
let action = roster_reconcile_action(¤t, None, Ok(Some(vec!["owner-a".to_string()])));
|
||||
assert_eq!(action, RosterReconcileAction::Keep);
|
||||
}
|
||||
|
||||
@@ -570,7 +583,7 @@ mod tests {
|
||||
fn roster_growth_requests_process_restart_immediately() {
|
||||
let current = vec!["owner-a".to_string()];
|
||||
let fresh = vec!["owner-a".to_string(), "owner-c".to_string()];
|
||||
let action = roster_reconcile_action(¤t, None, Ok(fresh));
|
||||
let action = roster_reconcile_action(¤t, None, Ok(Some(fresh)));
|
||||
assert_eq!(action, RosterReconcileAction::RestartProcess);
|
||||
}
|
||||
|
||||
@@ -579,7 +592,7 @@ mod tests {
|
||||
fn roster_shrink_awaits_confirmation_first() {
|
||||
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
|
||||
let reduced = vec!["owner-a".to_string()];
|
||||
let action = roster_reconcile_action(¤t, None, Ok(reduced.clone()));
|
||||
let action = roster_reconcile_action(¤t, None, Ok(Some(reduced.clone())));
|
||||
assert_eq!(action, RosterReconcileAction::AwaitConfirm(reduced));
|
||||
}
|
||||
|
||||
@@ -588,7 +601,7 @@ mod tests {
|
||||
fn roster_shrink_requests_process_restart_once_confirmed() {
|
||||
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
|
||||
let reduced = vec!["owner-a".to_string()];
|
||||
let action = roster_reconcile_action(¤t, Some(&reduced), Ok(reduced.clone()));
|
||||
let action = roster_reconcile_action(¤t, Some(&reduced), Ok(Some(reduced.clone())));
|
||||
assert_eq!(action, RosterReconcileAction::RestartProcess);
|
||||
}
|
||||
|
||||
@@ -599,8 +612,11 @@ mod tests {
|
||||
let current = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||
let first_reduced = vec!["a".to_string(), "b".to_string()];
|
||||
let second_reduced = vec!["a".to_string()];
|
||||
let action =
|
||||
roster_reconcile_action(¤t, Some(&first_reduced), Ok(second_reduced.clone()));
|
||||
let action = roster_reconcile_action(
|
||||
¤t,
|
||||
Some(&first_reduced),
|
||||
Ok(Some(second_reduced.clone())),
|
||||
);
|
||||
assert_eq!(action, RosterReconcileAction::AwaitConfirm(second_reduced));
|
||||
}
|
||||
|
||||
@@ -609,10 +625,10 @@ mod tests {
|
||||
#[test]
|
||||
fn genuinely_empty_roster_awaits_then_restarts_to_self_only() {
|
||||
let current = vec!["owner-a".to_string()];
|
||||
let first = roster_reconcile_action(¤t, None, Ok(Vec::new()));
|
||||
let first = roster_reconcile_action(¤t, None, Ok(Some(Vec::new())));
|
||||
assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new()));
|
||||
let empty: Vec<String> = Vec::new();
|
||||
let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Vec::new()));
|
||||
let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Some(Vec::new())));
|
||||
assert_eq!(confirmed, RosterReconcileAction::RestartProcess);
|
||||
}
|
||||
|
||||
@@ -621,9 +637,10 @@ mod tests {
|
||||
fn roster_shrink_then_recovery_keeps_allowlist() {
|
||||
let current = vec!["owner-a".to_string(), "owner-b".to_string()];
|
||||
let reduced = vec!["owner-a".to_string()];
|
||||
let held = roster_reconcile_action(¤t, None, Ok(reduced.clone()));
|
||||
let held = roster_reconcile_action(¤t, None, Ok(Some(reduced.clone())));
|
||||
assert_eq!(held, RosterReconcileAction::AwaitConfirm(reduced.clone()));
|
||||
let recovered = roster_reconcile_action(¤t, Some(&reduced), Ok(current.clone()));
|
||||
let recovered =
|
||||
roster_reconcile_action(¤t, Some(&reduced), Ok(Some(current.clone())));
|
||||
assert_eq!(recovered, RosterReconcileAction::Keep);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,53 @@ fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
|
||||
>= now
|
||||
}
|
||||
|
||||
/// Whether the relay enforces NIP-43 relay membership.
|
||||
///
|
||||
/// This is a property of the *relay deployment*, not of any particular query
|
||||
/// result, and it is resolved from the relay's NIP-11 document (NIP-43 is
|
||||
/// advertised only when membership is actually enforced). It must never be
|
||||
/// inferred from an absent membership snapshot: on a closed relay a snapshot
|
||||
/// can also be missing because of a transient replication gap, and treating
|
||||
/// that as "open" would silently drop admission enforcement exactly when the
|
||||
/// relay is least healthy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MeshRelayMode {
|
||||
/// Membership is enforced (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`). Every
|
||||
/// participant is a NIP-43 direct member, so the membership snapshot is the
|
||||
/// trust boundary for both admission and routing.
|
||||
ClosedMembershipEnforced,
|
||||
/// Membership is not enforced. Signing in never writes a `relay_members`
|
||||
/// row, so the snapshot lists at most whoever was added out of band and is
|
||||
/// structurally useless as a trust boundary. Mesh runs unenforced (stock
|
||||
/// MeshLLM behaviour) and is scoped by the relay-derived mesh topic.
|
||||
OpenNoMembership,
|
||||
}
|
||||
|
||||
impl MeshRelayMode {
|
||||
pub(crate) fn is_open(self) -> bool {
|
||||
matches!(self, Self::OpenNoMembership)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve [`MeshRelayMode`] from a relay's NIP-11 document.
|
||||
///
|
||||
/// `buzz-relay` lists NIP-43 in `supported_nips` only when it has a stable
|
||||
/// signing key *and* `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, so the advertisement
|
||||
/// is a faithful proxy for "membership is enforced here". A document that omits
|
||||
/// it — including a malformed one with no `supported_nips` array — describes a
|
||||
/// relay where signing in never creates a member row.
|
||||
pub fn relay_mesh_mode_from_nip11(document: &serde_json::Value) -> MeshRelayMode {
|
||||
let advertises_nip43 = document
|
||||
.get("supported_nips")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43)));
|
||||
if advertises_nip43 {
|
||||
MeshRelayMode::ClosedMembershipEnforced
|
||||
} else {
|
||||
MeshRelayMode::OpenNoMembership
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_targets(targets: Vec<MeshServeTarget>) -> Vec<MeshServeTarget> {
|
||||
let mut by_endpoint_and_model = BTreeMap::<(String, String), MeshServeTarget>::new();
|
||||
for target in targets {
|
||||
@@ -173,13 +220,36 @@ fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value)
|
||||
}
|
||||
|
||||
pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
availability_from_events_for_mode(events, MeshRelayMode::ClosedMembershipEnforced)
|
||||
}
|
||||
|
||||
/// [`availability_from_events`], scoped to the relay's membership mode.
|
||||
///
|
||||
/// On a membership-enforcing relay the NIP-43 snapshot gates routing: a serve
|
||||
/// target is selectable only when its status note is signed by a current
|
||||
/// member. On a relay that does not enforce membership there is no such
|
||||
/// roster to intersect with — requiring one would make every serving peer
|
||||
/// permanently undiscoverable — so status notes are accepted on their own
|
||||
/// merits (fresh, well-formed, and carrying a valid owner-signed endpoint
|
||||
/// binding). The mesh topic is derived from the relay origin, so this widens
|
||||
/// discovery to that relay's participants, not to the public internet.
|
||||
pub fn availability_from_events_for_mode(
|
||||
events: Vec<nostr::Event>,
|
||||
mode: MeshRelayMode,
|
||||
) -> MeshAvailability {
|
||||
if events.is_empty() {
|
||||
return MeshAvailability::unavailable("Buzz shared compute status is not published yet");
|
||||
}
|
||||
let Some(members) = latest_membership_list(&events) else {
|
||||
return MeshAvailability::unavailable(
|
||||
"Buzz shared compute is waiting for the current member roster",
|
||||
);
|
||||
let members = match latest_membership_list(&events) {
|
||||
Some(members) => Some(members),
|
||||
// An enforcing relay always publishes a snapshot (even a zero-member
|
||||
// one), so its absence there means an incomplete read: stay closed.
|
||||
None if !mode.is_open() => {
|
||||
return MeshAvailability::unavailable(
|
||||
"Buzz shared compute is waiting for the current member roster",
|
||||
);
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Status is replaceable per member pubkey, so a query returns multiple
|
||||
@@ -191,12 +261,14 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
|
||||
let now = nostr::Timestamp::now().as_secs();
|
||||
for event in events {
|
||||
if event.kind.as_u16() as u64 != MESH_STATUS_KIND
|
||||
|| !status_is_fresh(&event, now)
|
||||
|| !members.contains(&event.pubkey.to_hex().to_ascii_lowercase())
|
||||
{
|
||||
if event.kind.as_u16() as u64 != MESH_STATUS_KIND || !status_is_fresh(&event, now) {
|
||||
continue;
|
||||
}
|
||||
if let Some(members) = members.as_ref() {
|
||||
if !members.contains(&event.pubkey.to_hex().to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ pub use coordinator::{start_coordinator, MeshCoordinator, KIND_BUZZ_MESH_MEMBER_
|
||||
|
||||
mod discovery;
|
||||
pub use discovery::{
|
||||
availability_from_events, mesh_status_filter, owner_ids_from_events, relay_membership_filter,
|
||||
availability_from_events, availability_from_events_for_mode, mesh_status_filter,
|
||||
owner_ids_from_events, relay_membership_filter, relay_mesh_mode_from_nip11, MeshRelayMode,
|
||||
};
|
||||
pub(crate) use discovery::{
|
||||
current_member_pubkeys, has_membership_snapshot, MESH_STATUS_PAGE_SIZE,
|
||||
|
||||
@@ -665,6 +665,133 @@ fn owner_roster_without_membership_list_fails_closed() {
|
||||
assert!(super::owner_ids_from_events(&events).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_mesh_mode_reads_nip43_advertisement() {
|
||||
use super::MeshRelayMode;
|
||||
|
||||
// `buzz-relay` advertises NIP-43 only when membership is enforced, so the
|
||||
// advertisement is the deployment-mode signal.
|
||||
assert_eq!(
|
||||
super::relay_mesh_mode_from_nip11(&json!({"supported_nips": [1, 11, 42, 43]})),
|
||||
MeshRelayMode::ClosedMembershipEnforced
|
||||
);
|
||||
// The live open relay's actual shape (no 43).
|
||||
assert_eq!(
|
||||
super::relay_mesh_mode_from_nip11(
|
||||
&json!({"supported_nips": [1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56]})
|
||||
),
|
||||
MeshRelayMode::OpenNoMembership
|
||||
);
|
||||
// A missing or malformed document describes a relay that cannot be
|
||||
// enforcing membership, so it is treated as open. Callers that could not
|
||||
// reach the relay at all keep the enforcing default instead of calling in
|
||||
// here — an unreachable relay must never relax admission.
|
||||
assert_eq!(
|
||||
super::relay_mesh_mode_from_nip11(&json!({})),
|
||||
MeshRelayMode::OpenNoMembership
|
||||
);
|
||||
assert_eq!(
|
||||
super::relay_mesh_mode_from_nip11(&json!({"supported_nips": "not-an-array"})),
|
||||
MeshRelayMode::OpenNoMembership
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_relay_routes_without_a_membership_snapshot() {
|
||||
use super::MeshRelayMode;
|
||||
|
||||
// The bug this fixes: on a relay that does not enforce membership, no
|
||||
// kind:13534 snapshot ever lists ordinary users, so requiring one made
|
||||
// every serving peer permanently undiscoverable — each node sat at
|
||||
// "1 NODE" with an empty peer list forever.
|
||||
let endpoint = test_endpoint_token();
|
||||
let events = vec![signed_reporter_target(
|
||||
&"a".repeat(64),
|
||||
"model-open",
|
||||
&endpoint,
|
||||
)];
|
||||
|
||||
let closed = super::availability_from_events_for_mode(
|
||||
events.clone(),
|
||||
MeshRelayMode::ClosedMembershipEnforced,
|
||||
);
|
||||
assert!(
|
||||
closed.serve_targets.is_empty(),
|
||||
"an enforcing relay must still wait for its roster"
|
||||
);
|
||||
|
||||
let open = super::availability_from_events_for_mode(events, MeshRelayMode::OpenNoMembership);
|
||||
assert_eq!(open.serve_targets.len(), 1);
|
||||
assert_eq!(open.serve_targets[0].endpoint_addr, endpoint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_relay_without_snapshot_never_silently_opens_up() {
|
||||
use super::MeshRelayMode;
|
||||
|
||||
// Regression guard for the risk this change introduces. On an enforcing
|
||||
// relay a missing snapshot is a transient read gap, NOT permission to
|
||||
// admit everyone: if that ever resolved to the open path, one replication
|
||||
// blip would drop admission enforcement on a closed relay.
|
||||
let events = vec![
|
||||
signed_reporter_target(&"b".repeat(64), "model-a", &test_endpoint_token()),
|
||||
signed_reporter_target(&"c".repeat(64), "model-b", &test_endpoint_token()),
|
||||
];
|
||||
|
||||
let availability =
|
||||
super::availability_from_events_for_mode(events, MeshRelayMode::ClosedMembershipEnforced);
|
||||
assert!(availability.serve_targets.is_empty());
|
||||
assert_eq!(
|
||||
availability.reason.as_deref(),
|
||||
Some("Buzz shared compute is waiting for the current member roster")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_relay_still_enforces_freshness_and_endpoint_binding() {
|
||||
use super::MeshRelayMode;
|
||||
|
||||
// Dropping the membership intersection must not drop the other
|
||||
// protections: a stale note is still unroutable, and an unsigned endpoint
|
||||
// substitution is still rejected.
|
||||
let stale = signed_reporter_target_at(
|
||||
&"d".repeat(64),
|
||||
"stale-model",
|
||||
&test_endpoint_token(),
|
||||
1_000,
|
||||
);
|
||||
let stale_availability =
|
||||
super::availability_from_events_for_mode(vec![stale], MeshRelayMode::OpenNoMembership);
|
||||
assert!(
|
||||
stale_availability.serve_targets.is_empty(),
|
||||
"freshness still gates routing on an open relay"
|
||||
);
|
||||
|
||||
let signed_endpoint = test_endpoint_token();
|
||||
let mut forged = serde_json::from_str::<serde_json::Value>(
|
||||
&signed_reporter_target(&"e".repeat(64), "model-x", &signed_endpoint).content,
|
||||
)
|
||||
.expect("status content is json");
|
||||
// Swap in an endpoint the owner never signed for. Assert the substitution
|
||||
// is real so this test can never pass vacuously by reusing the same token.
|
||||
let substituted_endpoint = test_endpoint_token();
|
||||
assert_ne!(signed_endpoint, substituted_endpoint);
|
||||
forged["serveTargets"][0]["endpointAddr"] = json!(substituted_endpoint);
|
||||
let keys = nostr::Keys::parse(&"e".repeat(64)).unwrap();
|
||||
let substituted = super::coordinator::build_status_report_event(forged)
|
||||
.expect("status builder")
|
||||
.sign_with_keys(&keys)
|
||||
.expect("signs");
|
||||
let forged_availability = super::availability_from_events_for_mode(
|
||||
vec![substituted],
|
||||
MeshRelayMode::OpenNoMembership,
|
||||
);
|
||||
assert!(
|
||||
forged_availability.serve_targets.is_empty(),
|
||||
"endpoint binding is still verified on an open relay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_usage_extracts_local_and_remote_attempts() {
|
||||
// Captured shape from a live serving node's status payload. The local vs
|
||||
|
||||
Reference in New Issue
Block a user