diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0..3789121a52 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -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 { + 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, 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, 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 Result, 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>, 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, String> { - let events = query_mesh_discovery_events_at(state, relay_url).await?; - Ok(mesh_llm::owner_ids_from_events(&events)) +) -> Result>, 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 { +pub(crate) async fn resolve_trusted_owner_ids_or_self_only( + state: &AppState, +) -> Option> { 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, 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, Option) { - match query_mesh_discovery_events_at(state, relay_url).await { +) -> (Option>, Option) { + 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() { diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index 066fa46373..1cf0751656 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -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, String>, + query: Result>, 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 = 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); } diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index 2df4a7f8c5..e917229a6d 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -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) -> Vec { 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) -> 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, + 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) -> 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::(&event.content) else { continue; }; diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c53886..e2466b55c9 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -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, diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index 557cd040fa..97ae2290e8 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -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::( + &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