From 548952b87dba1539e2365959dd4bed8631b5494b Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 10:52:35 -0500 Subject: [PATCH] fix(relay): preserve refusal accounting across live admission Restore audio stored-owner failure accounting and keep live policy revalidation separate from deny-set revocation. Preserve denied-only priority over weak removal claims; cover denied and unavailable root closes. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-relay/src/audio/handler.rs | 6 ++ crates/buzz-relay/src/state.rs | 82 +++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 9663926bf6..4f2c7d1813 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -1108,6 +1108,12 @@ async fn run_active_audio_connection( let admitted_owner = match admitted_owner { Ok(owner) => owner, Err(denial) => { + crate::handlers::auth::record_enforce_refusal( + &control, + Some(&denial), + nip_fi_assertion.as_ref(), + ); + auth_line.finish(AuthResult::from_outcome(denial.outcome)); shadow_attempt.refused(); exit_authorization_refusal( &mut ws_send, diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index f24746124e..14bfceed2e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -392,7 +392,13 @@ impl CommunityConnectionControl { CommunityDisconnectReason::AuthorizationDenied }; let won = self.reason_tx.send_if_modified(|current| match current { - None | Some(CommunityDisconnectReason::Removed) => { + None => { + *current = Some(reason); + true + } + Some(CommunityDisconnectReason::Removed) + if class == buzz_auth::DenialClass::AuthorizationDenied => + { *current = Some(reason); true } @@ -444,6 +450,7 @@ impl CommunityConnectionControl { self.manager_disconnect_nip_fi_with_class( frame_tx, buzz_auth::DenialClass::AuthorizationDenied, + crate::nip_fi_shadow::EnforceSession::revoke, ); } @@ -451,12 +458,13 @@ impl CommunityConnectionControl { &self, frame_tx: &mpsc::Sender, class: buzz_auth::DenialClass, + enforce: fn(&crate::nip_fi_shadow::EnforceSession), ) { self.publish_nip_fi_denial( crate::nip_fi_session::NipFiWsRoute::Root, class, Some(frame_tx), - crate::nip_fi_shadow::EnforceSession::revoke, + enforce, ); self.cancel.cancel(); } @@ -1609,7 +1617,7 @@ impl ConnectionManager { if nip_fi { entry .community_control - .manager_disconnect_nip_fi_with_class(&entry.terminal_ctrl_tx, class); + .manager_disconnect_nip_fi_with_class(&entry.terminal_ctrl_tx, class, |_| {}); } else { entry .community_control @@ -3663,6 +3671,74 @@ pub(crate) mod tests { ); } + /// Live policy failures close root sockets without attributing them to + /// the NIP-FI deny set, just as the audio registry does. + #[test] + fn live_revalidation_does_not_record_deny_set_revocation() { + for class in [ + buzz_auth::DenialClass::AuthorizationDenied, + buzz_auth::DenialClass::AuthorizationUnavailable, + ] { + let (records, lines, _, ()) = + crate::nip_fi_shadow::tests::enforce_observed(async {}, |()| async move { + let manager = ConnectionManager::new(); + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let conn_id = Uuid::new_v4(); + let key = nostr::Keys::generate().public_key(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_tx, mut terminal_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason = control.disconnect_reason(); + control.attach_nip_fi_enforce(crate::nip_fi_shadow::EnforceSession::new( + "ws", + crate::nip_fi_shadow::BOUND.into(), + Some(key), + )); + control.nip_fi_enforce().expect("attached").admit(key); + manager.register( + conn_id, + tx, + ctrl_tx, + terminal_tx, + None, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + control, + ); + manager.set_authenticated_identity( + conn_id, + key.to_bytes().to_vec(), + Some(NipFiSession::new("test-issuer", None)), + ); + assert!(manager.disconnect_live_authorization(community, conn_id, class)); + assert!(cancel.is_cancelled()); + assert_eq!( + *reason.borrow(), + Some( + if class == buzz_auth::DenialClass::AuthorizationUnavailable { + CommunityDisconnectReason::AuthorizationUnavailable + } else { + CommunityDisconnectReason::AuthorizationDenied + } + ), + ); + assert!(terminal_rx.try_recv().is_ok(), "terminal denial frame"); + }); + assert_eq!(records.iter().filter(|r| *r == "ws admit/admit").count(), 1); + assert!( + records + .iter() + .all(|r| !r.contains("end/") && !r.contains("deny/")), + "{class:?} must not be attributed to the deny set: {lines:?}" + ); + } + } + #[test] fn nip_fi_disconnect_audio_is_issuer_scoped() { let registry = CommunityConnectionRegistry::new();