diff --git a/.gitignore b/.gitignore index f26e74136c0..9e47b5e06c9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ typesense-data/ doc/ /repos/ +# buzz-relay test-runtime pack cache (created by test harness, must not be committed) +crates/buzz-relay/repos/ + # Local identity files identity.key **/identity.key diff --git a/Cargo.lock b/Cargo.lock index 4d07a8bce5f..875df49aed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1057,6 +1057,7 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "dashmap", "futures-util", "hex", "jsonwebtoken", @@ -1423,6 +1424,7 @@ dependencies = [ "hex", "hmac 0.13.0", "infer", + "jsonwebtoken", "metrics", "metrics-exporter-prometheus", "metrics-util", diff --git a/Justfile b/Justfile index aff819573cc..da298e23dd1 100644 --- a/Justfile +++ b/Justfile @@ -467,6 +467,87 @@ test-unit: # unit job either. cargo nextest run -p buzz-relay --lib \ -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/)' + # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_* and + # api::nip_fi modules, plus the exact NIP-FI tests added to mixed + # modules (state, connection, audio::handler, router, handlers::*, + # config). They ran in NO lane before, the same gap as above. Mixed + # modules are listed by exact name so main's unselected tests (several + # wait out the ~30s sqlx acquire timeout) stay out; the NIP-FI stub-pool + # helpers use a 100ms acquire timeout. NIP-FI tests that need Postgres + # or Redis live in postgres_tests and run in the PostgreSQL lane. + cargo nextest run -p buzz-relay --lib -E ' + test(/^nip_fi_(config|gate|session|upgrade)::/) + test(/^api::nip_fi::/) + + test(=audio::handler::tests::admin_disconnect_nip_fi_delivers_restricted_json_then_policy_close) + + test(=audio::handler::tests::audio_expiry_sends_exact_restricted_frame_before_close) + + test(=audio::handler::tests::b1_already_expired_session_denied_at_pairing_before_admission) + + test(=audio::handler::tests::b1_mid_admission_expiry_does_not_add_peer_to_room) + + test(=audio::handler::tests::cw6_guard_release_before_commit_calls_directory_release_exactly_once) + + test(=audio::handler::tests::cw7_guard_release_before_commit_sends_clean_close_on_remote_stream) + + test(=audio::handler::tests::handle_active_audio_connection_pairing_mismatch_runs_full_audio_denial_path) + + test(=audio::handler::tests::pre_send_loop_check_cancel_emits_restricted_json_then_policy_close) + + test(=audio::handler::tests::w8_membership_check_barrier_fires_before_db_read) + + test(=audio::handler::tests::w_admin_disconnect_at_deny_check_delivers_payload_then_close) + + test(=audio::handler::tests::w_audio_deny_absent_key_passes_deny_check_reaches_membership_gate) + + test(=audio::handler::tests::w_audio_deny_active_key_refused_at_post_registration_check) + + test(=audio::handler::tests::w_audio_deny_straddle_entry_inserted_between_registration_and_check_is_caught) + + test(=config::tests::hermetic_for_test_is_deterministic) + + test(=connection::tests::b2_cancelled_connection_event_frame_not_dispatched) + + test(=connection::tests::b3_denial_precedes_restart_when_denial_already_won) + + test(=connection::tests::b3_expiry_denial_precedes_close_through_send_loop) + + test(=connection::tests::b3_root_pairing_denial_precedes_close_through_send_loop) + + test(=connection::tests::deadline_exp_is_earliest_selects_exp) + + test(=connection::tests::deadline_max_connection_lifetime_is_earliest_selects_partition) + + test(=connection::tests::deadline_no_lifetime_returns_upstream_only) + + test(=connection::tests::expiry_notice_queued_on_ctrl_before_cancel) + + test(=connection::tests::r1_denial_precedes_restart_reason_won_frame_arrives_during_recv) + + test(=handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated) + + test(=handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path) + + test(=handlers::event::tests::w_observer_permit_cancel_before_acquisition_blocks_publication) + + test(=router::tests::b4_connection_upgrade_only_no_upgrade_header_not_gated) + + test(=router::tests::b4_upgrade_only_no_connection_header_not_gated) + + test(=router::tests::deny_map_admits_key_not_in_map) + + test(=router::tests::deny_map_blocks_ws_admission_for_live_entry) + + test(=router::tests::nip_fi_enforce_audio_denies_missing_assertion_401) + + test(=router::tests::nip_fi_enforce_audio_denies_token_when_no_verifier_503) + + test(=router::tests::nip_fi_enforce_nip11_content_negotiation_serves_200_not_401) + + test(=router::tests::nip_fi_enforce_root_denies_missing_assertion_401) + + test(=router::tests::nip_fi_enforce_root_denies_token_when_no_verifier_503) + + test(=router::tests::nip_fi_enforce_ws_upgrade_with_html_accept_is_gated_401) + + test(=state::tests::auth_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + + test(=state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection) + + test(=state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped) + + test(=state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason) + + test(=state::tests::delete_wins_reason_losing_auth_does_not_enqueue_frame) + + test(=state::tests::delete_wins_reason_losing_expiry_does_not_enqueue_frame) + + test(=state::tests::delete_wins_reason_losing_manager_does_not_enqueue_frame) + + test(=state::tests::delete_wins_reason_losing_pairing_does_not_enqueue_frame) + + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + + test(=state::tests::disconnect_nip_fi_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::drain_all_jittered_cancels_when_restart_channel_is_full_or_closed) + + test(=state::tests::drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling) + + test(=state::tests::expiry_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::lifecycle_cancel_does_not_enqueue_frame_but_cancels_token) + + test(=state::tests::nip_fi_disconnect_audio_is_issuer_scoped) + + test(=state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason) + + test(=state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer) + + test(=state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket) + + test(=state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket) + + test(=state::tests::nip_fi_disconnect_then_community_keeps_authorization_denied_reason) + + test(=state::tests::pairing_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::w_audio_registry_lifecycle_cancel_race_payload_precedes_audio_teardown_cancel) + + test(=state::tests::w_auth_cancel_race_payload_precedes_community_cancel) + + test(=state::tests::w_cancel_race_deny_payload_precedes_community_cancel) + + test(=state::tests::w_expiry_cancel_race_payload_precedes_community_cancel) + + test(=state::tests::w_lifecycle_cancel_race_payload_precedes_lifecycle_cancel) + + test(=state::tests::w_lifecycle_cancel_race_reverse_manager_loses_after_lifecycle_wins) + + test(=state::tests::w_manager_cancel_race_payload_precedes_community_cancel) + + test(=state::tests::w_pairing_cancel_race_payload_precedes_community_cancel) + + test(=state::tests::w_root_manager_drain_race_payload_precedes_drain_lifecycle_cancel)' + # buzz-pubsub unit tests (NIP-FI disconnect fan-out codec) are + # infra-free and ran in no lane. + cargo nextest run -p buzz-pubsub --lib # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index 6cbe491e2c8..93833103dbc 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -20,6 +20,7 @@ tokio = { workspace = true, features = ["test-util"] } buzz-core = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } +dashmap = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } futures-util = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index c21872351f1..84efaa5cd68 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -45,14 +45,19 @@ pub use rate_limit::{ }; pub use scope::{parse_scopes, Scope}; +/// Re-export `jsonwebtoken::Algorithm` so crates that use NIP-FI issuer policy +/// construction do not need a direct `jsonwebtoken` dependency. +pub use jsonwebtoken::Algorithm as JwtAlgorithm; pub use nip_fi::{ validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, - ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, - FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, - IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, - JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, - ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, - TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, + ClientSubjectPosture, CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, + CommandVerifier, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull, + FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, + HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy, + IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, + NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, + SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, + VerifierError, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; diff --git a/crates/buzz-auth/src/nip_fi/assertion.rs b/crates/buzz-auth/src/nip_fi/assertion.rs index 8b8a566cf60..5e6b9a9a2a6 100644 --- a/crates/buzz-auth/src/nip_fi/assertion.rs +++ b/crates/buzz-auth/src/nip_fi/assertion.rs @@ -207,6 +207,48 @@ impl fmt::Debug for VerifiedAssertion { } } +#[cfg(any(test, feature = "test-utils"))] +impl VerifiedAssertion { + /// Test-only factory for building `VerifiedAssertion` fixtures without + /// going through the full JWT/JWKS verification path. NOT available in + /// production builds. + /// + /// # Panics + /// + /// Panics when `authority_deadlines` is empty — an empty set violates the + /// non-empty invariant that `upstream_authority_deadline()` relies on. + pub fn for_test( + asserted_key: Option, + authority_deadlines: Vec>, + ) -> Self { + assert!( + !authority_deadlines.is_empty(), + "VerifiedAssertion::for_test: authority_deadlines must be non-empty \ + (upstream_authority_deadline() panics on empty)" + ); + use super::config::{AssertionPolicyId, TransportContractId}; + Self { + identity: FederatedIdentity { + issuer: "test-issuer".to_string(), + subject: "test-subject".to_string(), + }, + asserted_key, + capabilities: CanonicalCapabilities::from_pairs(vec![]), + authority_deadlines, + assertion_policy_id: AssertionPolicyId::zero(), + transport_contract_id: TransportContractId::zero(), + revalidation_dependencies: RevalidationDependencies { + verification_key_id: "test-kid".to_string(), + key_snapshot_generation: 0, + key_snapshot_hard_deadline: DateTime::::MAX_UTC, + confidential_assertion: ConfidentialAssertion { + compact_jws: "test.test.test".to_string(), + }, + }, + } + } +} + impl RevalidationDependencies { pub(super) fn new( verification_key_id: String, diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs new file mode 100644 index 00000000000..3e4627f035c --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -0,0 +1,1300 @@ +//! NIP-FI command JWT verification — `VerifyCommandJwt`. +//! +//! Implements the `VerifyCommandJwt` procedure from +//! [NIP-FI.md](../../../../docs/nips/NIP-FI.md) §Admin disconnect API. +//! +//! ## Procedure (steps numbered as in the spec) +//! +//! 1. Bounded decode + `typ` check (`nip-fi-command+jwt` only). +//! 2. Select issuer policy; verify signature with authenticated JWKS. +//! 3. Validate all pure claims: iss, aud, time bounds, method/path/cmd, +//! target_pubkey, and until ceiling. +//! 4. Principal authorization (issuer-configured authorized `sub` list). +//! 5. Signed-target / request-body agreement. +//! 6. Atomic jti reservation + deny-entry insertion (both-or-neither). +//! 7. Return [`CommandResult`]. +//! +//! Fail-closed: any failure returns an error without side effects. The jti is +//! burned and the deny entry is inserted only on success. + +use chrono::{DateTime, Utc}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use nostr::PublicKey; +use serde_json::{Map, Value}; + +use super::config::{IssuerPolicy, IssuerRegistry, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES}; +use super::deny_map::{NipFiDenyMap, ReserveError}; +use super::verifier::{ + enforce_compact_structure, enforce_signature_shape, parse_header, parse_numeric_date, + parse_unique_claims, select_unique_jwk, validate_jwk, AssertionKeySet, IssuerKeySource, + VerifierError, +}; + +/// The expected `typ` value for command JWTs ([NIP-FI.md §Command JWT]). +pub const COMMAND_JWT_TYP: &str = "nip-fi-command+jwt"; +/// The expected `cmd` claim value. +const COMMAND_CMD: &str = "disconnect"; +/// Normative upper bound on `maximum_command_age_seconds` per the spec. +pub const MAX_COMMAND_AGE_SECONDS: u64 = 60; + +// ── Per-issuer command policy ───────────────────────────────────────────────── + +/// The per-issuer configuration additions required by the command API. +/// +/// These supplement the base [`IssuerPolicy`]: `maximum_command_age` and the +/// set of authorized issuer principals (the `sub` values allowed to send +/// commands). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandIssuerPolicy { + issuer: String, + /// `0 < maximum_command_age_seconds <= 60`. + maximum_command_age_seconds: u64, + /// Non-empty set of authorized `sub` values. + authorized_principals: Vec, + /// Hard ceiling on the number of live deny entries for this issuer. + deny_set_capacity: usize, +} + +/// Why a [`CommandIssuerPolicy`] could not be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CommandPolicyError { + /// `maximum_command_age_seconds` was 0 or > 60 (normative bound). + #[error("maximum_command_age_seconds must be in [1, 60]")] + InvalidCommandAge, + /// The authorized principals list was empty. + #[error("authorized_principals must be non-empty")] + EmptyAuthorizedPrincipals, + /// A principal string was empty or too long. + #[error("an authorized principal value is invalid")] + InvalidPrincipal, + /// `deny_set_capacity` was 0. + #[error("deny_set_capacity must be positive")] + ZeroCapacity, + /// The issuer string was empty. + #[error("issuer must be non-empty")] + EmptyIssuer, +} + +impl CommandIssuerPolicy { + /// Validate and construct a command policy. + pub fn new( + issuer: String, + maximum_command_age_seconds: u64, + authorized_principals: Vec, + deny_set_capacity: usize, + ) -> Result { + if issuer.is_empty() { + return Err(CommandPolicyError::EmptyIssuer); + } + if maximum_command_age_seconds == 0 || maximum_command_age_seconds > MAX_COMMAND_AGE_SECONDS + { + return Err(CommandPolicyError::InvalidCommandAge); + } + if authorized_principals.is_empty() { + return Err(CommandPolicyError::EmptyAuthorizedPrincipals); + } + if authorized_principals + .iter() + .any(|p| p.is_empty() || p.len() > MAX_SUBJECT_BYTES) + { + return Err(CommandPolicyError::InvalidPrincipal); + } + if deny_set_capacity == 0 { + return Err(CommandPolicyError::ZeroCapacity); + } + Ok(Self { + issuer, + maximum_command_age_seconds, + authorized_principals, + deny_set_capacity, + }) + } + + /// The exact `iss` this policy applies to. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// `0 < maximum_command_age_seconds <= 60`. + pub const fn maximum_command_age_seconds(&self) -> u64 { + self.maximum_command_age_seconds + } + + /// Non-empty set of authorized `sub` values. + pub fn authorized_principals(&self) -> &[String] { + &self.authorized_principals + } + + /// Hard ceiling on the number of live deny entries for this issuer. + pub const fn deny_set_capacity(&self) -> usize { + self.deny_set_capacity + } +} + +// ── Command verifier ────────────────────────────────────────────────────────── + +/// The sealed result of a successful `VerifyCommandJwt` call. +/// +/// Side effects (jti reservation + deny entry) have been committed atomically +/// before this is returned. The caller should proceed to close matching +/// sessions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandResult { + /// The target pubkey from the signed JWT (and verified against the body). + pub target_pubkey: PublicKey, + /// Issuer URI of the authorized caller. + pub caller_iss: String, + /// `sub` of the authorized caller. + pub caller_sub: String, + /// The `until` timestamp from the signed JWT. + pub until: DateTime, +} + +/// Errors from [`CommandVerifier::verify`]. +/// +/// Each variant maps to an exact HTTP status and response body per the spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CommandError { + /// Malformed, invalid, or expired command JWT → 403. + #[error("evidence rejected")] + EvidenceRejected, + /// Principal not authorized; signed-target mismatch; replayed jti → 403. + #[error("authorization denied")] + AuthorizationDenied, + /// Per-issuer deny-set capacity exceeded → 503. Neither the jti nor the + /// deny entry was recorded; the caller may safely retry the same command. + #[error("deny set full")] + DenySetFull, + /// JWKS snapshot unavailable → 503. + #[error("authorization unavailable")] + AuthorizationUnavailable, + /// `until` exceeds the allowed ceiling → 400. + #[error("until exceeds ceiling")] + UntilExceedsCeiling, + /// Malformed request body → 400. + #[error("malformed request")] + MalformedRequest, +} + +impl CommandError { + /// HTTP status code for the command API endpoint response. + pub const fn http_status(self) -> u16 { + match self { + Self::EvidenceRejected | Self::AuthorizationDenied => 403, + Self::DenySetFull | Self::AuthorizationUnavailable => 503, + Self::UntilExceedsCeiling | Self::MalformedRequest => 400, + } + } + + /// Spec-exact response body bytes (trailing `\n` included). + pub const fn response_body(self) -> &'static str { + match self { + Self::EvidenceRejected => "evidence rejected\n", + Self::AuthorizationDenied => "authorization denied\n", + Self::DenySetFull => "deny set full\n", + Self::AuthorizationUnavailable => "authorization unavailable\n", + Self::UntilExceedsCeiling | Self::MalformedRequest => "bad request\n", + } + } +} + +/// The NIP-FI command JWT verifier. +/// +/// Holds a reference to the shared issuer registry (for policy + JWKS lookup), +/// the key source, the per-issuer command policies, and the deny map it writes +/// to. +/// +/// `S` must be `Clone` so the verifier can be shared cheaply via `Arc::clone`. +pub struct CommandVerifier { + registry: IssuerRegistry, + key_source: S, + /// Indexed by exact `iss`. + command_policies: std::collections::HashMap, + deny_map: NipFiDenyMap, +} + +impl CommandVerifier { + /// Construct a command verifier. + /// + /// `command_policies` must cover every issuer that may send commands; + /// an unlisted issuer is rejected as `EvidenceRejected`. + pub fn new( + registry: IssuerRegistry, + key_source: S, + command_policies: Vec, + deny_map: NipFiDenyMap, + ) -> Self { + let map = command_policies + .into_iter() + .map(|p| (p.issuer.clone(), p)) + .collect(); + Self { + registry, + key_source, + command_policies: map, + deny_map, + } + } + + /// A shared reference to the deny map this verifier writes to. + /// + /// S5 (HTTP enforcement) reads this same map from the shared `AppState` + /// without going through the verifier. The reference here is for callers + /// that need to pass the map into the WS admission check. + pub fn deny_map(&self) -> &NipFiDenyMap { + &self.deny_map + } + + /// Execute `VerifyCommandJwt` at current clock time. + /// + /// Parameters: + /// * `token` — compact JWS from `Nostr-Federated-Identity: Bearer`. + /// * `request_method` — HTTP method (expected `"POST"`). + /// * `request_path` — HTTP path (expected `"/api/nip-fi/disconnect"`). + /// * `body_pubkey` — the `pubkey` field parsed from the JSON body. + /// + /// On `Ok`, the jti is reserved and the deny entry is inserted. + /// On `Err`, no side effects have occurred (or, on `DenySetFull`, neither + /// mutation was applied, so retry is safe). + pub fn verify( + &self, + token: &str, + request_method: &str, + request_path: &str, + body_pubkey: &PublicKey, + ) -> Result { + self.verify_at(token, request_method, request_path, body_pubkey, Utc::now()) + } + + /// Verify with an injectable clock for deterministic testing. + pub fn verify_at( + &self, + token: &str, + request_method: &str, + request_path: &str, + body_pubkey: &PublicKey, + now: DateTime, + ) -> Result { + // ── Step 1: bounded decode + typ check ─────────────────────────────── + if token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(CommandError::EvidenceRejected); + } + enforce_compact_structure(token).map_err(|_| CommandError::EvidenceRejected)?; + let header = parse_header(token).map_err(|_| CommandError::EvidenceRejected)?; + enforce_signature_shape(token).map_err(|_| CommandError::EvidenceRejected)?; + + // typ MUST be exactly "nip-fi-command+jwt". + if header.typ.as_deref() != Some(COMMAND_JWT_TYP) { + return Err(CommandError::EvidenceRejected); + } + + // ── Step 2: select issuer policy; verify signature ──────────────────── + let claims = parse_unique_claims(token).map_err(|_| CommandError::EvidenceRejected)?; + + let signed_iss = claim_str(&claims, "iss").ok_or(CommandError::EvidenceRejected)?; + + let base_policy = self + .registry + .policy_for_issuer(signed_iss) + .ok_or(CommandError::EvidenceRejected)?; + + let cmd_policy = self + .command_policies + .get(signed_iss) + .ok_or(CommandError::EvidenceRejected)?; + + if !base_policy.algorithms().contains(&header.algorithm) { + return Err(CommandError::EvidenceRejected); + } + + let key_set = self + .key_source + .key_set(base_policy.issuer()) + .ok_or(CommandError::AuthorizationUnavailable)?; + if key_set.issuer() != base_policy.issuer() { + return Err(CommandError::EvidenceRejected); + } + + verify_jwt_signature(token, base_policy, &key_set, header.algorithm).map_err( + |e| match e { + VerifierError::KeySourceUnavailable | VerifierError::StatusWitnessUnavailable => { + CommandError::AuthorizationUnavailable + } + _ => CommandError::EvidenceRejected, + }, + )?; + + // ── Step 3: validate pure claims ────────────────────────────────────── + + // aud: at least one of the policy audiences must match. + let aud_ok = match claims.get("aud") { + Some(Value::String(s)) => base_policy.audiences().iter().any(|a| a == s), + Some(Value::Array(arr)) => arr.iter().any(|v| { + v.as_str() + .map(|s| base_policy.audiences().iter().any(|a| a == s)) + .unwrap_or(false) + }), + _ => false, + }; + if !aud_ok { + return Err(CommandError::EvidenceRejected); + } + + // Time bounds. + let iat = numeric_date(&claims, "iat")?; + let exp = numeric_date(&claims, "exp")?; + let skew = chrono::Duration::seconds(base_policy.skew_seconds() as i64); + let max_cmd_age = chrono::Duration::seconds(cmd_policy.maximum_command_age_seconds as i64); + let iat_plus_cmd_age = iat + .checked_add_signed(max_cmd_age) + .ok_or(CommandError::EvidenceRejected)?; + + // now < exp (equality is expired). + if now >= exp { + return Err(CommandError::EvidenceRejected); + } + // iat <= now + skew. + let now_plus_skew = now + .checked_add_signed(skew) + .ok_or(CommandError::EvidenceRejected)?; + if iat > now_plus_skew { + return Err(CommandError::EvidenceRejected); + } + // now < iat + maximum_command_age. + if now >= iat_plus_cmd_age { + return Err(CommandError::EvidenceRejected); + } + + // method / path / cmd (exact literal matches required by spec). + let method = claim_str(&claims, "method").ok_or(CommandError::EvidenceRejected)?; + let path = claim_str(&claims, "path").ok_or(CommandError::EvidenceRejected)?; + let cmd = claim_str(&claims, "cmd").ok_or(CommandError::EvidenceRejected)?; + if method != request_method { + return Err(CommandError::EvidenceRejected); + } + if path != request_path { + return Err(CommandError::EvidenceRejected); + } + if cmd != COMMAND_CMD { + return Err(CommandError::EvidenceRejected); + } + + // target_pubkey: lowercase hex of exactly 32 bytes. + let target_hex = + claim_str(&claims, "target_pubkey").ok_or(CommandError::EvidenceRejected)?; + let target_pubkey = parse_hex_pubkey(target_hex).ok_or(CommandError::EvidenceRejected)?; + + // until: NumericDate. + let until = numeric_date(&claims, "until")?; + + // Validate `until` ceiling: until <= now + skew + maximum_assertion_age. + let max_assertion_age = + chrono::Duration::seconds(base_policy.maximum_assertion_age_seconds() as i64); + let deny_ceiling = now_plus_skew + .checked_add_signed(max_assertion_age) + .ok_or(CommandError::EvidenceRejected)?; + if until > deny_ceiling { + return Err(CommandError::UntilExceedsCeiling); + } + + // jti: must be present and non-empty. + let jti = claim_str(&claims, "jti").ok_or(CommandError::EvidenceRejected)?; + + // ── Step 4: principal authorization ─────────────────────────────────── + // AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub). + let sub = claim_str(&claims, "sub").ok_or(CommandError::EvidenceRejected)?; + if !cmd_policy.authorized_principals.iter().any(|p| p == sub) { + return Err(CommandError::AuthorizationDenied); + } + + // ── Step 5: signed-target / request-body agreement ─────────────────── + if &target_pubkey != body_pubkey { + return Err(CommandError::AuthorizationDenied); + } + + // ── Steps 6+7: atomic jti reservation + deny-entry insertion ───────── + // + // effective_expiry = min(exp, iat + maximum_command_age). + let effective_expiry = exp.min(iat_plus_cmd_age); + + match self.deny_map.atomic_reserve_and_insert( + base_policy.issuer(), + jti, + effective_expiry, + &target_pubkey, + until, + now, + ) { + Ok(()) => {} + Err(ReserveError::JtiAlreadyReserved) => return Err(CommandError::AuthorizationDenied), + Err(ReserveError::CapacityExceeded) => return Err(CommandError::DenySetFull), + } + + Ok(CommandResult { + target_pubkey, + caller_iss: base_policy.issuer().to_owned(), + caller_sub: sub.to_owned(), + until, + }) + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn claim_str<'a>(claims: &'a Map, key: &str) -> Option<&'a str> { + claims.get(key)?.as_str().filter(|s| !s.is_empty()) +} + +fn numeric_date(claims: &Map, key: &str) -> Result, CommandError> { + let value = claims.get(key).ok_or(CommandError::EvidenceRejected)?; + parse_numeric_date(value).map_err(|_| CommandError::EvidenceRejected) +} + +fn parse_hex_pubkey(raw: &str) -> Option { + if raw.len() != 64 + || !raw + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return None; + } + PublicKey::from_hex(raw).ok() +} + +/// Verify the JWS signature using the JWKS from `key_set`. +/// +/// This reuses the same `select_unique_jwk_pub` / `validate_jwk_pub` helpers as +/// the assertion verifier so key selection and validation semantics are identical. +fn verify_jwt_signature( + token: &str, + policy: &IssuerPolicy, + key_set: &AssertionKeySet, + algorithm: Algorithm, +) -> Result<(), VerifierError> { + use base64::Engine; + + // Decode the header segment to extract `kid`. + let header_seg = token + .split('.') + .next() + .ok_or(VerifierError::MalformedToken)?; + let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(header_seg) + .map_err(|_| VerifierError::MalformedToken)?; + let header_obj: serde_json::Map = + serde_json::from_slice(&header_bytes).map_err(|_| VerifierError::MalformedToken)?; + let kid = header_obj + .get("kid") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or(VerifierError::MissingKeyId)?; + + let jwk = select_unique_jwk(key_set.jwks(), kid)?; + validate_jwk(jwk, algorithm)?; + let key = DecodingKey::from_jwk(jwk).map_err(|_| VerifierError::InvalidKey)?; + + let mut validation = Validation::new(algorithm); + validation.set_issuer(&[policy.issuer()]); + validation.set_audience(policy.audiences()); + validation.set_required_spec_claims(&["exp", "iat", "iss", "aud"]); + validation.validate_exp = false; + validation.validate_nbf = false; + decode::>(token, &key, &validation) + .map_err(|_| VerifierError::InvalidSignatureOrClaims)?; + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::nip_fi::deny_map::IssuerCapacity; + use chrono::{Duration, Utc}; + + // ── CommandIssuerPolicy validation ──────────────────────────────────────── + // These tests exercise the construction-time validation contract: + // every misconfiguration is caught before any command is processed. + + #[test] + fn command_policy_rejects_zero_age() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 0, + vec!["admin@example.com".into()], + 1000, + ); + assert_eq!( + result, + Err(CommandPolicyError::InvalidCommandAge), + "maximum_command_age=0 must be rejected" + ); + } + + #[test] + fn command_policy_rejects_age_exceeding_60() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 61, + vec!["admin@example.com".into()], + 1000, + ); + assert_eq!( + result, + Err(CommandPolicyError::InvalidCommandAge), + "maximum_command_age=61 must be rejected (normative bound is 60)" + ); + } + + #[test] + fn command_policy_accepts_max_age_60() { + CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 60, + vec!["admin@example.com".into()], + 1000, + ) + .expect("maximum_command_age=60 must be accepted (normative upper bound)"); + } + + #[test] + fn command_policy_accepts_min_age_1() { + CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 1, + vec!["admin@example.com".into()], + 1000, + ) + .expect("maximum_command_age=1 must be accepted (normative lower bound)"); + } + + #[test] + fn command_policy_rejects_empty_principals() { + let result = + CommandIssuerPolicy::new("https://issuer.example.com".into(), 30, vec![], 1000); + assert_eq!( + result, + Err(CommandPolicyError::EmptyAuthorizedPrincipals), + "empty authorized_principals must be rejected" + ); + } + + #[test] + fn command_policy_rejects_zero_capacity() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 30, + vec!["admin@example.com".into()], + 0, + ); + assert_eq!( + result, + Err(CommandPolicyError::ZeroCapacity), + "deny_set_capacity=0 must be rejected" + ); + } + + #[test] + fn command_policy_rejects_empty_issuer() { + let result = + CommandIssuerPolicy::new(String::new(), 30, vec!["admin@example.com".into()], 1000); + assert_eq!( + result, + Err(CommandPolicyError::EmptyIssuer), + "empty issuer must be rejected" + ); + } + + // ── CommandError HTTP contract ──────────────────────────────────────────── + // Spec-exact status codes and bodies from the disconnect API response table. + + #[test] + fn command_error_http_contract_is_spec_exact() { + // Evidence failures → 403 "evidence rejected\n" + assert_eq!(CommandError::EvidenceRejected.http_status(), 403); + assert_eq!( + CommandError::EvidenceRejected.response_body(), + "evidence rejected\n" + ); + + // Authorization failures → 403 "authorization denied\n" + assert_eq!(CommandError::AuthorizationDenied.http_status(), 403); + assert_eq!( + CommandError::AuthorizationDenied.response_body(), + "authorization denied\n" + ); + + // Capacity → 503 "deny set full\n" + assert_eq!(CommandError::DenySetFull.http_status(), 503); + assert_eq!(CommandError::DenySetFull.response_body(), "deny set full\n"); + + // JWKS unavailable → 503 "authorization unavailable\n" + assert_eq!(CommandError::AuthorizationUnavailable.http_status(), 503); + assert_eq!( + CommandError::AuthorizationUnavailable.response_body(), + "authorization unavailable\n" + ); + + // until ceiling / malformed → 400 "bad request\n" + assert_eq!(CommandError::UntilExceedsCeiling.http_status(), 400); + assert_eq!( + CommandError::UntilExceedsCeiling.response_body(), + "bad request\n" + ); + assert_eq!(CommandError::MalformedRequest.http_status(), 400); + assert_eq!( + CommandError::MalformedRequest.response_body(), + "bad request\n" + ); + } + + // ── Mutation evidence: time-bound oracles ───────────────────────────────── + // + // The tests below exercise `verify_at` via real ES256-signed command JWTs. + // The same test key and JWKS helpers as `verifier/tests.rs` are used so + // key-selection and claim-validation semantics are identical. + + // FI-TRACE-DENY-SET oracle: past-until inserts with expired value. + #[test] + fn past_until_command_creates_no_future_denial_when_no_active_entry() { + use nostr::Keys; + + let deny_map = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: "https://issuer.example.com".to_owned(), + capacity: 100, + }], + ); + let k = Keys::generate().public_key(); + let now = Utc::now(); + let past = now - Duration::seconds(60); + + // Directly invoke the deny map: past-until on absent entry. + deny_map + .atomic_reserve_and_insert( + "https://issuer.example.com", + "jti-past", + past, + &k, + past, + now, + ) + .expect("past-until on absent entry must succeed (not a capacity error)"); + + // is_denied now must be false (entry is immediately expired). + assert!( + !deny_map.is_denied("https://issuer.example.com", &k, now), + "past-until command on absent entry creates no future denial [FI-TRACE-DENY-SET]" + ); + } + + // FI-TRACE-DENY-SET oracle: both delivery orders → max(until_A, until_B). + #[test] + fn deny_set_both_delivery_orders_give_max_until() { + use nostr::Keys; + + let iss = "https://issuer.example.com"; + let now = Utc::now(); + let k = Keys::generate().public_key(); + + // Order 1: longer first, shorter second. + { + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 100, + }], + ); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + m.atomic_reserve_and_insert(iss, "jti-a1", longer, &k, longer, now) + .unwrap(); + m.atomic_reserve_and_insert(iss, "jti-b1", shorter, &k, shorter, now) + .unwrap(); + // At t = 400s: still denied (longer survives shorter). + assert!( + m.is_denied(iss, &k, now + Duration::seconds(400)), + "Order 1 (longer first): deny at 400s must hold under merge rule" + ); + } + + // Order 2: shorter first, longer second. + { + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 100, + }], + ); + let shorter = now + Duration::seconds(300); + let longer = now + Duration::seconds(600); + m.atomic_reserve_and_insert(iss, "jti-a2", shorter, &k, shorter, now) + .unwrap(); + m.atomic_reserve_and_insert(iss, "jti-b2", longer, &k, longer, now) + .unwrap(); + // At t = 400s: still denied (shorter did not shorten the longer). + assert!( + m.is_denied(iss, &k, now + Duration::seconds(400)), + "Order 2 (shorter first): deny at 400s must hold — delivery order must not shorten longer deny" + ); + } + } + + // ── Full-path CommandVerifier::verify_at tests (real ES256 key) ─────────── + // + // Uses the same test key material as `verifier/tests.rs` so the key- + // selection and signature-validation paths are exercised identically. + // Each test carries a named mutation anchor: the assertion that goes wrong + // when the guarded code path is removed. + + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + const TEST_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + const TEST_KID: &str = "cmd-test-key-1"; + const ISS: &str = "https://idp.example.com"; + const AUD: &str = "https://relay.example.com"; + const PRINCIPAL: &str = "admin@idp.example.com"; + const METHOD: &str = "POST"; + const PATH: &str = "/api/nip-fi/disconnect"; + + fn test_jwks_cmd() -> jsonwebtoken::jwk::JwkSet { + serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", + "use": "sig", "alg": "ES256", + "kid": TEST_KID, + "x": TEST_JWK_X, + "y": TEST_JWK_Y, + }] + })) + .expect("valid test JWKS") + } + + fn test_issuer_policy() -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{ISS}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid contract"); + IssuerPolicy::new( + ISS.to_owned(), + vec![AUD.to_owned()], + crate::nip_fi::config::TokenClass::DedicatedNipFi, + crate::nip_fi::config::FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + fn test_command_policy() -> CommandIssuerPolicy { + CommandIssuerPolicy::new(ISS.to_owned(), 30, vec![PRINCIPAL.to_owned()], 1000) + .expect("valid cmd policy") + } + + fn test_command_verifier() -> CommandVerifier { + use crate::nip_fi::verifier::{AssertionKeySet, StaticIssuerKeySource}; + let future = Utc::now() + Duration::seconds(3600); + let key_set = AssertionKeySet::new(ISS.to_owned(), 1, test_jwks_cmd(), future) + .expect("valid key set"); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let deny_map = NipFiDenyMap::new( + 1000, + vec![IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 1000, + }], + ); + CommandVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + vec![test_command_policy()], + deny_map, + ) + } + + fn now_ts() -> i64 { + Utc::now().timestamp() + } + + fn target_key() -> nostr::PublicKey { + nostr::Keys::generate().public_key() + } + + /// Mint a real ES256 command JWT with optional claim overrides. + fn mint_cmd_jwt( + target: &nostr::PublicKey, + until_offset_secs: i64, + overrides: serde_json::Value, + ) -> String { + let now = now_ts(); + let mut claims = serde_json::json!({ + "iss": ISS, + "aud": AUD, + "sub": PRINCIPAL, + "iat": now, + "exp": now + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, + "path": PATH, + "cmd": "disconnect", + "target_pubkey": target.to_hex(), + "until": now + until_offset_secs, + }); + if let serde_json::Value::Object(ref ov) = overrides { + for (k, v) in ov { + claims[k] = v.clone(); + } + } + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some(COMMAND_JWT_TYP.to_owned()); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") + } + + // ── Happy path ──────────────────────────────────────────────────────────── + + #[test] + fn full_path_happy_path_returns_ok_and_inserts_deny() { + // Mutation anchor: removing the deny-entry insertion (step 6) makes + // is_denied return false even after Ok — caught here. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let result = cv.verify_at(&token, METHOD, PATH, &target, now); + assert!(result.is_ok(), "happy path must succeed: {result:?}"); + assert!( + cv.deny_map() + .is_denied(ISS, &target, now + Duration::seconds(1)), + "deny entry must be inserted on success [FI-TRACE-DENY-SET]" + ); + } + + // ── typ / signature ─────────────────────────────────────────────────────── + + #[test] + fn wrong_typ_rejects_as_evidence_rejected() { + // Mutation anchor: removing the typ check admits at+jwt tokens as commands. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("at+jwt".to_owned()); + let claims = serde_json::json!({ + "iss": ISS, "aud": AUD, "sub": PRINCIPAL, + "iat": now_ts(), "exp": now_ts() + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, "path": PATH, "cmd": "disconnect", + "target_pubkey": target.to_hex(), "until": now_ts() + 300, + }); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).unwrap(); + let token = jsonwebtoken::encode(&header, &claims, &key).unwrap(); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "wrong typ must be EvidenceRejected" + ); + } + + #[test] + fn corrupted_signature_rejects_as_evidence_rejected() { + // Mutation anchor: removing sig verification admits forged tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let mut token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let last = token.pop().unwrap(); + token.push(if last == 'A' { 'B' } else { 'A' }); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "corrupted signature must be EvidenceRejected" + ); + } + + // ── aud ─────────────────────────────────────────────────────────────────── + + #[test] + fn wrong_aud_rejects_as_evidence_rejected() { + // Mutation anchor: removing aud check admits tokens for other relays. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"aud": "https://other.relay.example.com"}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "wrong aud must be EvidenceRejected" + ); + } + + // ── Time bounds ─────────────────────────────────────────────────────────── + + #[test] + fn expired_token_rejects_as_evidence_rejected() { + // Mutation anchor: removing exp check admits expired tokens indefinitely. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let past = now_ts() - 600; + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": past, "exp": past + 55}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "expired token must be EvidenceRejected" + ); + } + + #[test] + fn future_iat_beyond_skew_rejects() { + // Mutation anchor: removing iat>now+skew check admits pre-issued tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let far_future_iat = now_ts() + 300; // 5 min future, skew=30s + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": far_future_iat, "exp": far_future_iat + 55}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "future iat > now+skew must be EvidenceRejected" + ); + } + + #[test] + fn token_older_than_command_age_rejects() { + // Mutation anchor: removing iat+cmd_age check admits stale commands. + let cv = test_command_verifier(); + let target = target_key(); + // iat at exactly max_command_age ago (30s) = now >= iat+30 → expired. + let stale_iat = now_ts() - 30; + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": stale_iat, "exp": stale_iat + 55}), + ); + let now = Utc::now(); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "token at iat+max_cmd_age must be EvidenceRejected (equality is expired)" + ); + } + + // ── method / path / cmd ─────────────────────────────────────────────────── + + #[test] + fn wrong_method_rejects() { + // Mutation anchor: removing method check admits GET command tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"method": "GET"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + #[test] + fn wrong_path_rejects() { + // Mutation anchor: removing path check admits tokens for other endpoints. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"path": "/api/other"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + #[test] + fn wrong_cmd_value_rejects() { + // Mutation anchor: removing cmd check admits other command type tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"cmd": "reconnect"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + // ── target_pubkey / body agreement ─────────────────────────────────────── + + #[test] + fn body_target_mismatch_rejects_as_authorization_denied() { + // Mutation anchor: removing the target==body check lets attackers + // disconnect a different pubkey than they signed. + let cv = test_command_verifier(); + let signed_target = target_key(); + let body_target = target_key(); // different + let now = Utc::now(); + let token = mint_cmd_jwt(&signed_target, 300, serde_json::json!({})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &body_target, now), + Err(CommandError::AuthorizationDenied), + "target/body mismatch must be AuthorizationDenied" + ); + } + + // ── Authorization ───────────────────────────────────────────────────────── + + #[test] + fn unauthorized_sub_rejects_as_authorization_denied() { + // Mutation anchor: removing sub check lets any bearer of a valid JWT + // issue disconnect commands. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"sub": "not-admin@idp.example.com"}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::AuthorizationDenied), + "unauthorized sub must be AuthorizationDenied" + ); + } + + // ── until ceiling ───────────────────────────────────────────────────────── + + #[test] + fn until_exceeds_ceiling_returns_correct_error() { + // Mutation anchor: removing ceiling check allows unbounded deny duration. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let far_future = now_ts() + 10 * 365 * 24 * 3600; + let token = mint_cmd_jwt(&target, 0, serde_json::json!({"until": far_future})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::UntilExceedsCeiling), + "until beyond ceiling must be UntilExceedsCeiling" + ); + } + + // ── jti is the LAST step ───────────────────────────────────────────────── + + #[test] + fn jti_replay_rejected_after_first_success() { + // Mutation anchor: moving jti before auth checks would burn the jti + // on a bad-auth token; replay would be indistinguishable from a new call. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let jti = uuid::Uuid::new_v4().to_string(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"jti": jti})); + assert!(cv.verify_at(&token, METHOD, PATH, &target, now).is_ok()); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::AuthorizationDenied), + "replayed jti must be AuthorizationDenied" + ); + } + + // ── 503 does NOT burn the jti ───────────────────────────────────────────── + + #[test] + fn capacity_503_does_not_burn_jti_retry_succeeds_after_slot_freed() { + // Mutation anchor: if jti were recorded before the capacity check, a + // retry after freeing capacity would be spuriously rejected as replay. + // The spec guarantees: 503 leaves the command replayable. + // + // Setup: capacity=1, first command fills the slot. + // Step 1: second command with jti_b → 503 (capacity full). + // Step 2: "expire" the first entry by using a future `now` that is + // past target_a's until; lazy eviction fires on the next mutation. + // Step 3: retry with the same jti_b → must succeed (jti was NOT burned). + use crate::nip_fi::verifier::{AssertionKeySet, StaticIssuerKeySource}; + + let future = Utc::now() + Duration::seconds(3600); + let key_set = AssertionKeySet::new(ISS.to_owned(), 1, test_jwks_cmd(), future) + .expect("valid key set"); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + // capacity = 1: one slot + let deny_map = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 1, + }], + ); + let cv = CommandVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + vec![test_command_policy()], + deny_map, + ); + + let target_a = target_key(); + let target_b = target_key(); + let t0 = Utc::now(); + + // Fill the single slot with target_a. Use until_offset_secs=3 so the + // deny entry expires at t0+3s, well before t1. + let token_a = mint_cmd_jwt(&target_a, 3, serde_json::json!({})); + assert!(cv.verify_at(&token_a, METHOD, PATH, &target_a, t0).is_ok()); + + // target_b → 503 (capacity full); jti_b is NOT burned. + let jti_b = uuid::Uuid::new_v4().to_string(); + let token_b = mint_cmd_jwt(&target_b, 300, serde_json::json!({"jti": jti_b})); + assert_eq!( + cv.verify_at(&token_b, METHOD, PATH, &target_b, t0), + Err(CommandError::DenySetFull), + "must be DenySetFull (503)" + ); + + // Advance `now` to t0+5s: target_a's entry (until=t0+3) is expired. + // Lazy eviction fires on the next mutation inside verify_at, freeing + // the slot. token_b is still within command age (iat=t0, max=30s). + let t1 = t0 + Duration::seconds(5); + + // Retry with the SAME jti_b at t1. Lazy eviction removes target_a's + // entry, freeing the slot. + // Must succeed: 503 must NOT have burned jti_b. + assert!( + cv.verify_at(&token_b, METHOD, PATH, &target_b, t1).is_ok(), + "retry with same jti after slot freed must succeed — 503 must NOT burn the jti" + ); + } + + // ── Signed fractional verify_at witness ────────────────────────────────── + // + // Proves that a real ES256-signed command JWT whose `until` NumericDate is + // fractional (whole seconds + 0.5) passes through CommandVerifier::verify_at + // with the fractional deadline intact in CommandResult.until, and that the + // inserted deny entry correctly honours the sub-second boundary. + // + // Mandatory reds: + // - truncating/zeroing fractional `until` in parse_numeric_date → returned + // deadline loses nanos; boundary assertions fail + // - bypassing verify_at with a synthetic CommandResult → this test goes + // through the real verifier path and the boundary assertions still fail + // when the map is queried with the wrong deadline + + #[test] + fn fractional_until_survives_verify_at_and_preserves_boundary() { + use chrono::TimeZone; + + let cv = test_command_verifier(); + let target = target_key(); + + // Construct a command JWT with until = T + 0.5s expressed as a fractional + // NumericDate float. The test key's maximum_assertion_age is 3600s so + // any `until` within now+3600+skew passes the ceiling check. + let now = Utc::now(); + + // Pick a whole-second base that is within the assertion-age ceiling. + let t_whole_secs = now.timestamp() + 300; // 5 min from now + let t_frac_secs: f64 = t_whole_secs as f64 + 0.5; // T + 500ms + + let claims = serde_json::json!({ + "iss": ISS, + "aud": AUD, + "sub": PRINCIPAL, + "iat": now.timestamp(), + "exp": now.timestamp() + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, + "path": PATH, + "cmd": "disconnect", + "target_pubkey": target.to_hex(), + "until": t_frac_secs, + }); + // Ensure `until` is encoded as a JSON number (float), not a string. + assert!( + claims["until"].is_f64(), + "until must be a JSON number for this test to exercise the fractional path" + ); + + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some(COMMAND_JWT_TYP.to_owned()); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid EC PEM"); + let token = jsonwebtoken::encode(&header, &claims, &key).expect("sign"); + + // verify_at with `now` as the controlled clock. + let result = cv.verify_at(&token, METHOD, PATH, &target, now); + assert!( + result.is_ok(), + "fractional verify_at must succeed: {result:?}" + ); + let cmd = result.unwrap(); + + // The returned CommandResult.until must preserve the fractional nanos. + let expected_until = chrono::Utc + .timestamp_opt(t_whole_secs, 500_000_000) + .single() + .expect("representable timestamp"); + assert_eq!( + cmd.until, expected_until, + "CommandResult.until must retain the 500ms fractional nanos from the signed JWT" + ); + + // The deny entry inserted by verify_at must respect the sub-second boundary. + let deny_map = cv.deny_map(); + + // Denied immediately after T (T+1ns, well inside T+500ms). + let now_after_t = chrono::Utc.timestamp_opt(t_whole_secs, 1).single().unwrap(); + assert!( + deny_map.is_denied(ISS, &target, now_after_t), + "must be denied at T+1ns (deadline is T+500ms)" + ); + + // Denied at T+499_999_999ns (just before the boundary). + let now_before_boundary = chrono::Utc + .timestamp_opt(t_whole_secs, 499_999_999) + .single() + .unwrap(); + assert!( + deny_map.is_denied(ISS, &target, now_before_boundary), + "must be denied at deadline - 1ns" + ); + + // Admitted at exact equality (now == until): `now < until` is false at equality. + assert!( + !deny_map.is_denied(ISS, &target, expected_until), + "must be admitted at exact equality with the fractional deadline" + ); + + // Also admitted past the deadline. + let now_past = chrono::Utc + .timestamp_opt(t_whole_secs + 1, 0) + .single() + .unwrap(); + assert!( + !deny_map.is_denied(ISS, &target, now_past), + "must be admitted past the fractional deadline" + ); + } +} diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 8dabb00b12b..df9e48a5806 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -104,6 +104,13 @@ impl AssertionPolicyId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// Zero value for tests. Available in production builds only with the + /// `test-utils` feature enabled. + #[cfg(any(test, feature = "test-utils"))] + pub const fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for AssertionPolicyId { @@ -144,6 +151,13 @@ impl TransportContractId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// Zero value for tests. Available in production builds only with the + /// `test-utils` feature enabled. + #[cfg(any(test, feature = "test-utils"))] + pub const fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for TransportContractId { diff --git a/crates/buzz-auth/src/nip_fi/deny_map.rs b/crates/buzz-auth/src/nip_fi/deny_map.rs new file mode 100644 index 00000000000..3fa304293e3 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/deny_map.rs @@ -0,0 +1,1167 @@ +//! In-memory NIP-FI deny set. +//! +//! Holds `(iss, pubkey) → until` entries. No persistence — a relay restart +//! forgets active entries (Option B, as decided). The issuer re-push path is +//! documented as the mitigation but is not implemented here. +//! +//! ## Invariants +//! +//! * **Merge rule**: inserting a new `until` for an existing key retains +//! `max(existing_until, new_until)` — an accepted disconnect MUST NOT shorten +//! an active deny. [FI-TRACE-DENY-SET] +//! * **Past-`until` commands**: close sessions but MUST NOT create or shorten +//! entries. Concretely, when `new_until < now` the merge still applies the +//! `max` rule, which preserves any active entry and lets a "no active entry" +//! case insert with an already-expired value (immediately inactive). [FI-TRACE-DENY-SET] +//! * **Per-issuer capacity cap**: each issuer has a hard ceiling on live entries. +//! A capacity failure returns `Err(DenySetFull)` without inserting anything — +//! the spec requires `503` here and neither the jti nor the deny entry is +//! recorded. [FI-TRACE-DENY-SET] +//! * **Cross-issuer isolation**: capacity of issuer A MUST NOT affect issuer B. +//! * **Cross-pod capacity miss**: `merge_cross_pod_deny` returning `CapacityExceeded` +//! preserves the existing shard contents; the caller closes the delivered +//! target's sessions and reports/metrics the outcome. No issuer-wide denial +//! is synthesized. Async propagation loss with issuer re-push is the +//! sanctioned recovery. [NIP-FI.md:306-336] +//! * **jti reservation** and **deny-entry insertion** are performed atomically +//! in one lock scope (both or neither). [VerifyCommandJwt step 7] +//! * **Issuer-global scope**: the deny applies across all communities served +//! under that issuer. [FI-TRACE-DENY-SET] +//! * **Self-eviction**: expired entries are pruned lazily on each mutation and +//! on read (deny check), so the map does not grow without bound. + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use nostr::PublicKey; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Returned when the per-issuer deny-set capacity is exhausted. +/// +/// The caller MUST respond `503` and MUST NOT record the jti; the same signed +/// command remains replayable (the command identity was not consumed). +/// [VerifyCommandJwt step 7] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("deny set full for issuer")] +pub struct DenySetFull; + +// ── Per-issuer shard ────────────────────────────────────────────────────────── + +/// One issuer's worth of deny entries and jti deduplication state. +/// +/// The shard mutex is acquired once per `AtomicReserveJtiAndDenyEntry` call +/// so both mutations happen under the same lock (both-or-neither atomicity). +struct IssuerShard { + /// Active deny entries: hex-encoded pubkey → until. + entries: HashMap>, + /// Reserved jtis: jti string → effective_expiry. Expired jtis are evicted + /// lazily on each write so the map never grows to replay-corpus size. + jtis: HashMap>, + /// Maximum number of live deny entries for this issuer. + capacity: usize, + /// Maximum number of live jti reservations for this issuer. + /// Bounded separately so an issuer cannot exhaust memory by replaying + /// distinct jtis faster than they expire, even for already-denied keys. + /// Set to `capacity * 2` at construction for O(capacity) memory with + /// headroom for in-flight update commands on already-denied keys. + max_jti_count: usize, +} + +impl IssuerShard { + fn new(capacity: usize) -> Self { + Self { + entries: HashMap::new(), + jtis: HashMap::new(), + capacity, + // JTI resource bound: allow up to 2× capacity JTI reservations. + // This gives one active command per entry slot plus headroom for + // one in-flight update command per already-denied key without + // blocking normal operation. Still O(capacity) memory. + max_jti_count: capacity.saturating_mul(2).max(1), + } + } + + /// Evict expired entries and jtis. Called inside the lock on every write. + fn evict_expired(&mut self, now: DateTime) { + self.entries.retain(|_, until| *until > now); + self.jtis.retain(|_, exp| *exp > now); + } + + /// True if `(iss, pubkey_hex)` has an active deny entry (`now < until`). + fn is_denied(&self, pubkey_hex: &str, now: DateTime) -> bool { + self.entries + .get(pubkey_hex) + .map(|until| now < *until) + .unwrap_or(false) + } + + /// Attempt the atomic jti-reservation + deny-entry insertion. + /// + /// **Atomicity**: both HashMap inserts are precomputed before any write. + /// Eviction is done first (pure mutation of existing map, always safe), + /// then all fallible pre-conditions are checked, then both inserts happen + /// under the same lock scope. An unwind before the inserts leaves the + /// shard unchanged; an unwind mid-insert is not possible because HashMap + /// insert is infallible after capacity reservation. + fn atomic_reserve_and_insert( + &mut self, + jti: &str, + jti_effective_expiry: DateTime, + pubkey_hex: &str, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + self.evict_expired(now); + + // Replay check: jti already in set → AuthorizationDenied. + if self.jtis.contains_key(jti) { + return Err(ReserveError::JtiAlreadyReserved); + } + + // JTI resource bound: cap live jti reservations at max_jti_count so an + // issuer cannot exhaust memory by sending distinct jtis for already-denied + // keys faster than they expire. Uses CapacityExceeded so the caller + // responds 503 and the command remains replayable (jti not burned). + if self.jtis.len() >= self.max_jti_count { + return Err(ReserveError::CapacityExceeded); + } + + // Deny-entry capacity check: only count as new if no active entry exists. + // The merge rule never increases live entry count. + let is_update = self + .entries + .get(pubkey_hex) + .map(|existing| now < *existing) + .unwrap_or(false); + if !is_update && self.entries.len() >= self.capacity { + return Err(ReserveError::CapacityExceeded); + } + + // Prebuild both values before writing anything. + let jti_key = jti.to_owned(); + let entry_key = pubkey_hex.to_owned(); + let effective_until = match self.entries.get(pubkey_hex) { + Some(&existing) => existing.max(until), + None => until, + }; + + // Both mutations are infallible HashMap inserts; executed together + // so no intermediate observable state exists. + self.jtis.insert(jti_key, jti_effective_expiry); + self.entries.insert(entry_key, effective_until); + + Ok(()) + } + + /// Merge a remote deny entry without consuming a jti. + /// + /// Used for cross-pod propagation where replay idempotency is achieved by + /// the max(until) merge rule alone — no jti tracking needed. + /// Returns `Err(CapacityExceeded)` if the entry is new and the shard is full. + fn remote_merge( + &mut self, + pubkey_hex: &str, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + self.evict_expired(now); + + // Capacity check: only count as new if there is no active entry. + let is_update = self + .entries + .get(pubkey_hex) + .map(|existing| now < *existing) + .unwrap_or(false); + if !is_update && self.entries.len() >= self.capacity { + return Err(ReserveError::CapacityExceeded); + } + + // max(existing_until, until) merge. + let effective_until = match self.entries.get(pubkey_hex) { + Some(&existing) => existing.max(until), + None => until, + }; + self.entries.insert(pubkey_hex.to_owned(), effective_until); + Ok(()) + } +} + +/// Reasons an atomic reserve can fail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReserveError { + /// The jti was already reserved — replay attempt. + JtiAlreadyReserved, + /// Per-issuer capacity ceiling reached. + CapacityExceeded, +} + +/// Outcome of a cross-pod deny merge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossPodMergeResult { + /// Entry was inserted or updated (max-merge applied). + Merged, + /// Issuer is not locally configured; message rejected. + UnknownIssuer, + /// Per-issuer capacity ceiling reached; the missed entry was not recorded. + /// The caller should close any sessions matching the delivered target despite + /// the capacity miss, and report/metric the outcome. + CapacityExceeded, + /// Shard mutex is poisoned; issuer is fail-closed. + ShardPoisoned, +} + +// ── Public map ──────────────────────────────────────────────────────────────── + +/// Relay-wide in-memory NIP-FI deny set. +/// +/// One `Arc` is held in `AppState`; the HTTP disconnect endpoint +/// and the WS admission check share it. +/// +/// The deny-check interface is intentionally transport-agnostic — S5 (HTTP +/// enforcement) calls `is_denied` from HTTP admission without any WS coupling. +#[derive(Clone)] +pub struct NipFiDenyMap { + /// Per-issuer shards. Each shard owns its own Mutex so cross-issuer + /// capacity exhaustion is impossible to cause cross-issuer denial. + shards: Arc>>, + /// Default per-issuer capacity, used when no issuer-specific override exists. + default_capacity: usize, +} + +/// A per-issuer capacity override supplied at construction time. +#[derive(Debug, Clone)] +pub struct IssuerCapacity { + /// The exact issuer URI this capacity applies to. + pub issuer: String, + /// Maximum number of live deny entries for this issuer. + pub capacity: usize, +} + +impl NipFiDenyMap { + /// Construct a new deny map. + /// + /// `default_capacity` is the per-issuer entry ceiling used for any issuer + /// not listed in `issuer_capacities`. Must be > 0. + /// + /// A zero capacity would make every command a 503; callers must validate + /// before construction. + pub fn new(default_capacity: usize, issuer_capacities: Vec) -> Self { + let shards: DashMap> = DashMap::new(); + for ic in issuer_capacities { + shards.insert(ic.issuer, Mutex::new(IssuerShard::new(ic.capacity))); + } + Self { + shards: Arc::new(shards), + default_capacity, + } + } + + /// Returns `true` when `(iss, pubkey)` has an active deny entry at `now`. + /// + /// Used by S4 (WS admission step 6) and S5 (HTTP admission step 5). + /// [FI-TRACE-DENY-SET] + /// + /// Fails **closed**: a poisoned shard lock returns `true` (deny) so that a + /// damaged shard cannot silently admit a denied pubkey. + pub fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool { + let pubkey_hex = pubkey.to_hex(); + match self.shards.get(issuer) { + Some(shard) => shard + .lock() + .map(|guard| guard.is_denied(&pubkey_hex, now)) + .unwrap_or(true), // poisoned shard → fail closed (deny) + None => false, + } + } + + /// Atomically reserve `(iss, jti)` and insert/merge the deny entry. + /// + /// Both mutations happen under the same per-issuer lock (both-or-neither). + /// + /// * `Ok(())` — success. + /// * `Err(ReserveError::JtiAlreadyReserved)` — replay; map is unchanged, + /// caller responds `AuthorizationDenied`. + /// * `Err(ReserveError::CapacityExceeded)` — full; map is unchanged, + /// caller responds `503 deny set full`. + /// + /// [VerifyCommandJwt step 7] + pub(crate) fn atomic_reserve_and_insert( + &self, + issuer: &str, + jti: &str, + jti_effective_expiry: DateTime, + pubkey: &PublicKey, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + let pubkey_hex = pubkey.to_hex(); + let shard = self + .shards + .entry(issuer.to_owned()) + .or_insert_with(|| Mutex::new(IssuerShard::new(self.default_capacity))); + shard + .lock() + .map_err(|_| ReserveError::CapacityExceeded) // poisoned = fail closed + .and_then(|mut guard| { + guard.atomic_reserve_and_insert(jti, jti_effective_expiry, &pubkey_hex, until, now) + }) + } + + /// Merge a cross-pod deny entry (e.g. from Redis propagation). + /// + /// Idempotent: repeated delivery of the same `(issuer, pubkey, until)` is + /// a no-op due to the `max(until)` merge rule. No synthetic jti is + /// allocated — replay idempotency is structural, not tracked. + /// + /// Only merges into **locally-configured** issuer shards. An unknown + /// issuer returns [`CrossPodMergeResult::UnknownIssuer`] so the consumer + /// can reject without allocating state. + /// + /// On capacity exhaustion, returns [`CrossPodMergeResult::CapacityExceeded`] + /// without altering the shard. The caller is responsible for closing the + /// delivered target's sessions and reporting/metricing the outcome; no + /// issuer-wide denial is synthesized. [NIP-FI.md:306-336] + pub fn merge_cross_pod_deny( + &self, + issuer: &str, + pubkey: &PublicKey, + until: DateTime, + now: DateTime, + ) -> CrossPodMergeResult { + let pubkey_hex = pubkey.to_hex(); + // Only operate on pre-configured shards — never allocate for unknown issuers. + match self.shards.get(issuer) { + None => CrossPodMergeResult::UnknownIssuer, + Some(shard) => match shard.lock() { + Err(_) => { + // Shard is poisoned — we cannot obtain the lock. A poisoned + // mutex already causes `is_denied` to return `true` (the + // `unwrap_or(true)` path), so the issuer is implicitly + // fail-closed without any explicit write. + CrossPodMergeResult::ShardPoisoned + } + Ok(mut guard) => match guard.remote_merge(&pubkey_hex, until, now) { + Ok(()) => CrossPodMergeResult::Merged, + Err(ReserveError::CapacityExceeded) => { + // Cannot record the deny entry — return the outcome so + // the caller can close the delivered target's sessions + // and report/metric the capacity miss. No issuer-wide + // denial is synthesized; active entries are preserved. + // [NIP-FI.md:306-336] + CrossPodMergeResult::CapacityExceeded + } + Err(ReserveError::JtiAlreadyReserved) => { + // remote_merge never touches jtis; this arm is unreachable. + unreachable!("remote_merge does not use jti tracking") + } + }, + }, + } + } + + /// Hex encoding of `pubkey`, for downstream logging and wire use. + pub fn pubkey_hex(pubkey: &PublicKey) -> String { + pubkey.to_hex() + } + + /// Poisons `iss`'s shard mutex so tests outside this crate can drive the + /// fail-closed `ShardPoisoned` paths. + #[cfg(any(test, feature = "test-utils"))] + pub fn poison_shard_for_test(&self, iss: &str) { + let shards = Arc::clone(&self.shards); + let iss = iss.to_owned(); + let _ = std::thread::spawn(move || { + let shard = shards.get(&iss).expect("shard must exist"); + let _guard = shard.lock(); + panic!("intentional shard poison (test)"); + }) + .join(); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use nostr::Keys; + + fn key() -> PublicKey { + Keys::generate().public_key() + } + + fn map() -> NipFiDenyMap { + NipFiDenyMap::new(100, vec![]) + } + + fn iss() -> &'static str { + "https://issuer.example.com" + } + + // ── FI-TRACE-DENY-SET: basic admit/deny ────────────────────────────────── + + #[test] + fn not_denied_when_no_entry() { + let m = map(); + assert!( + !m.is_denied(iss(), &key(), Utc::now()), + "no entry → admitted" + ); + } + + #[test] + fn denied_when_active_entry() { + let m = map(); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k, until, Utc::now()) + .expect("first insert"); + assert!(m.is_denied(iss(), &k, Utc::now()), "active entry → denied"); + } + + #[test] + fn admitted_after_until_expires() { + let m = map(); + let k = key(); + let until = Utc::now() - Duration::seconds(1); // already expired + m.atomic_reserve_and_insert(iss(), "jti-exp", until, &k, until, Utc::now()) + .expect("insert with past-until"); + // is_denied with `now` past `until` → not denied + assert!( + !m.is_denied(iss(), &k, Utc::now()), + "expired entry → admitted" + ); + } + + // ── FI-TRACE-DENY-SET: merge rule ──────────────────────────────────────── + + #[test] + fn merge_rule_longer_command_wins() { + let m = map(); + let k = key(); + let now = Utc::now(); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + + // Insert longer first. + m.atomic_reserve_and_insert(iss(), "jti-A", longer, &k, longer, now) + .expect("insert longer"); + // Insert shorter — must not shorten. + m.atomic_reserve_and_insert(iss(), "jti-B", shorter, &k, shorter, now) + .expect("insert shorter"); + + // Check just before shorter would expire (still in longer window). + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "merge rule: longer deny survives shorter command" + ); + } + + #[test] + fn merge_rule_longer_command_second_wins() { + let m = map(); + let k = key(); + let now = Utc::now(); + let shorter = now + Duration::seconds(300); + let longer = now + Duration::seconds(600); + + // Insert shorter first, then longer. + m.atomic_reserve_and_insert(iss(), "jti-A", shorter, &k, shorter, now) + .expect("insert shorter"); + m.atomic_reserve_and_insert(iss(), "jti-B", longer, &k, longer, now) + .expect("insert longer"); + + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "delivery order does not matter — longer wins regardless" + ); + } + + #[test] + fn past_until_command_over_active_entry_leaves_active_unchanged() { + let m = map(); + let k = key(); + let now = Utc::now(); + let active_until = now + Duration::seconds(600); + let past_until = now - Duration::seconds(60); + + // Active entry first. + m.atomic_reserve_and_insert(iss(), "jti-A", active_until, &k, active_until, now) + .expect("insert active"); + + // Past-until command: max(active_until, past_until) = active_until. + m.atomic_reserve_and_insert(iss(), "jti-B", past_until, &k, past_until, now) + .expect("past-until insert"); + + // Active entry unchanged. + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "past-until command must not shorten active deny" + ); + } + + #[test] + fn past_until_command_absent_entry_inserts_expired() { + let m = map(); + let k = key(); + let now = Utc::now(); + let past_until = now - Duration::seconds(60); + + // Past-until, no existing entry → insert with expired value → immediately inactive. + m.atomic_reserve_and_insert(iss(), "jti-A", past_until, &k, past_until, now) + .expect("past-until on absent entry"); + + // Not denied (entry is immediately expired). + assert!( + !m.is_denied(iss(), &k, now), + "past-until with no prior entry creates no future denial" + ); + } + + // ── Replay prevention ──────────────────────────────────────────────────── + + #[test] + fn jti_replay_is_rejected() { + let m = map(); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + + m.atomic_reserve_and_insert(iss(), "jti-same", until, &k, until, Utc::now()) + .expect("first use"); + let result = m.atomic_reserve_and_insert(iss(), "jti-same", until, &k, until, Utc::now()); + assert_eq!( + result, + Err(ReserveError::JtiAlreadyReserved), + "replayed jti must be rejected" + ); + } + + // ── Capacity ───────────────────────────────────────────────────────────── + + #[test] + fn jti_resource_bound_limits_replay_state_for_already_denied_keys() { + // max_jti_count = capacity * 2 = 4 (for capacity=2). + // Fill all 4 JTI slots across 2 keys, then verify a fifth jti is rejected. + // This proves the bound is enforced even though entry capacity is not + // exhausted (only 2 entries for 2 keys, entry capacity is 2 — no new + // entries would be inserted). + // + // Mutation anchor: removing the JTI resource-bound check would let + // the jtis map grow without limit even though no new deny entries are + // added (because the update path bypasses the entry-capacity check). + let m = NipFiDenyMap::new( + 2, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 2, + }], + ); + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k1 = key(); + let k2 = key(); + + // 4 commands across 2 keys: fills all 4 JTI slots (2 per key, 2*2=4). + m.atomic_reserve_and_insert(iss(), "jti-k1-a", until, &k1, until, now) + .expect("k1 first command"); + m.atomic_reserve_and_insert( + iss(), + "jti-k1-b", + until + Duration::seconds(1), + &k1, + until + Duration::seconds(1), + now, + ) + .expect("k1 second command (update, within jti bound)"); + m.atomic_reserve_and_insert(iss(), "jti-k2-a", until, &k2, until, now) + .expect("k2 first command"); + m.atomic_reserve_and_insert( + iss(), + "jti-k2-b", + until + Duration::seconds(1), + &k2, + until + Duration::seconds(1), + now, + ) + .expect("k2 second command (update, within jti bound)"); + + // 5th JTI: max_jti_count=4 exhausted → CapacityExceeded. + let result = m.atomic_reserve_and_insert( + iss(), + "jti-k1-c", + until + Duration::seconds(2), + &k1, + until + Duration::seconds(2), + now, + ); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "jti resource bound must reject the fifth jti (max_jti_count=4 exhausted)" + ); + } + + #[test] + fn capacity_exceeded_returns_error_without_inserting() { + // Capacity = 2, three distinct pubkeys. + let m = NipFiDenyMap::new(2, vec![]); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + let k1 = key(); + let k2 = key(); + let k3 = key(); + + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k1, until, now) + .expect("k1"); + m.atomic_reserve_and_insert(iss(), "jti-2", until, &k2, until, now) + .expect("k2"); + let result = m.atomic_reserve_and_insert(iss(), "jti-3", until, &k3, until, now); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "third distinct key must be rejected when cap=2" + ); + // k3 is NOT denied (entry was not inserted). + assert!(!m.is_denied(iss(), &k3, now), "k3 must not be denied"); + } + + #[test] + fn update_to_existing_key_does_not_count_against_entry_capacity() { + // capacity=2: two entry slots, but only one is used. + // An update to the existing key uses the second JTI slot but does NOT + // add a second entry — verifies the entry-capacity check allows updates. + // (JTI capacity is a separate bound: capacity=2 gives max_jti_count=2, + // so the second JTI fits without hitting the JTI resource bound either.) + let m = NipFiDenyMap::new(2, vec![]); + let now = Utc::now(); + let k = key(); + let until_a = now + Duration::seconds(300); + let until_b = now + Duration::seconds(600); + + m.atomic_reserve_and_insert(iss(), "jti-a", until_a, &k, until_a, now) + .expect("first insert"); + // Same key, longer until — entry count stays at 1 (update), jti count goes to 2. + m.atomic_reserve_and_insert(iss(), "jti-b", until_b, &k, until_b, now) + .expect("update same key must succeed: only one entry used, entry-capacity is 2"); + + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(400)), + "updated entry is active" + ); + } + + #[test] + fn cross_issuer_capacity_is_independent() { + let iss_a = "https://a.example.com"; + let iss_b = "https://b.example.com"; + // issuer A has capacity 1 + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss_a.to_owned(), + capacity: 1, + }], + ); + + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k1 = key(); + let k2 = key(); + let k3 = key(); + + // Fill issuer A. + m.atomic_reserve_and_insert(iss_a, "jti-a1", until, &k1, until, now) + .expect("iss_a k1"); + // Issuer B is at default capacity (100) → must accept. + m.atomic_reserve_and_insert(iss_b, "jti-b1", until, &k2, until, now) + .expect("iss_b k2 must succeed independent of iss_a capacity"); + // Issuer A is at capacity 1 → must reject. + let result = m.atomic_reserve_and_insert(iss_a, "jti-a2", until, &k3, until, now); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "iss_a capacity exhaustion must not affect iss_b, and vice versa" + ); + } + + // ── Poison-path: is_denied must fail closed ─────────────────────────────── + + #[test] + fn poisoned_shard_is_denied_fails_closed() { + let iss = "https://poison.example.com"; + // Construct the map with a pre-registered shard for this issuer. + let m = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let m_clone = std::sync::Arc::clone(&m); + let k = key(); + + // Poison the real IssuerShard by spawning a thread that acquires the + // shard Mutex (which wraps a real IssuerShard) and then panics. + // A thread panic while holding a Mutex guard poisons the mutex. + let _ = std::thread::spawn(move || { + let shard_ref = m_clone.shards.get(iss).expect("shard must exist"); + let _guard = shard_ref.lock().expect("lock acquired"); + panic!("intentional poison"); + }) + .join(); // Err(_) expected — that's the proof the thread panicked. + + // The shard is now poisoned. is_denied must return true (fail closed). + // Mutation anchor: reverting unwrap_or(true) → unwrap_or(false) makes + // this assertion fail — that is the defect Thufir identified in pass 1. + assert!( + m.is_denied(iss, &k, Utc::now()), + "poisoned shard must return true from is_denied (fail closed)" + ); + + // Confirm the normal path still works on a clean map. + let clean = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let k2 = key(); + let until2 = Utc::now() + Duration::seconds(300); + clean + .atomic_reserve_and_insert(iss, "jti-clean", until2, &k2, until2, Utc::now()) + .expect("insert on clean map"); + assert!( + clean.is_denied(iss, &k2, Utc::now()), + "active entry on clean map must return true" + ); + } + + // ── remote_merge: idempotent cross-pod semantics ───────────────────────── + + #[test] + fn remote_merge_shorter_after_longer_does_not_shorten() { + // Map with iss() pre-registered so remote_merge can operate on it. + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 100, + }], + ); + let k = key(); + let now = Utc::now(); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + + // First merge: longer. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, longer, now), + CrossPodMergeResult::Merged + ); + // Second merge: shorter — must not shorten. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, shorter, now), + CrossPodMergeResult::Merged + ); + // At 400s: still denied (longer wins). + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(400)), + "shorter-after-longer remote merge must not shorten the deny" + ); + } + + #[test] + fn remote_merge_replay_is_idempotent() { + // Map with iss() pre-registered so remote_merge can operate on it. + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 100, + }], + ); + let k = key(); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + // Deliver twice. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, until, now), + CrossPodMergeResult::Merged + ); + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, until, now), + CrossPodMergeResult::Merged + ); + // Still denied at 200s (no spurious second-insert count growth). + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(200)), + "replay must be idempotent" + ); + } + + #[test] + fn remote_merge_unknown_issuer_rejected() { + let m = map(); // default issuer is "https://issuer.example.com", not "unknown" + let k = key(); + let until = Utc::now() + Duration::seconds(300); + assert_eq!( + m.merge_cross_pod_deny("https://unknown.example.com", &k, until, Utc::now()), + CrossPodMergeResult::UnknownIssuer, + "unknown issuer must be rejected without allocating state" + ); + // No shard was created for the unknown issuer. + assert!( + m.shards.get("https://unknown.example.com").is_none(), + "no shard must be allocated for unknown issuer" + ); + } + + // ── remote_merge: capacity oracle (two-pod divergent model) ────────────── + // + // Verifies that ordinary remote capacity exhaustion leaves active entries + // intact, returns the capacity outcome, and does NOT synthesize issuer-wide + // denial or unrelated-key denial. Uses two capacity-1 maps modeling + // divergent pods with the same issuer. + // + // Mandatory reds: + // (a) guard/evict the active entry on capacity → original k1 entry gone; + // entry-retention assertion fails + // (b) synthesize issuer-wide denial (set blocked) → missed-target and + // unrelated-key is_denied assertions fail + // (c) admit at exact equality (use <= instead of <) → equality assertion fails + + #[test] + fn remote_merge_capacity_exceeded_preserves_active_entry_and_does_not_deny_missed_or_unrelated() + { + // Two capacity-1 maps modeling divergent pods (pod A, pod B). + // Pod A locally contains target k_a; pod B locally contains target k_b. + // Both have the same finite TTL. + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k_a = key(); + let k_b = key(); + let k_unrelated = key(); + + let make_map = |local_key: &PublicKey| { + let m = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 1, + }], + ); + // Pre-fill with the local target. + m.atomic_reserve_and_insert(iss(), "jti-local", until, local_key, until, now) + .expect("local pre-fill must succeed"); + m + }; + + // Pod A map: has k_a, receives k_b cross-pod. + let map_a = make_map(&k_a); + let result_a = map_a.merge_cross_pod_deny(iss(), &k_b, until, now); + assert_eq!( + result_a, + CrossPodMergeResult::CapacityExceeded, + "cross-delivery of k_b to pod A (capacity=1, already holds k_a) must return CapacityExceeded" + ); + // Pod A still has its original k_a entry — capacity miss must not evict. + assert!( + map_a.is_denied(iss(), &k_a, now), + "pod A must still deny k_a after capacity miss" + ); + // Pod A must NOT deny the missed k_b via issuer-wide block. + assert!( + !map_a.is_denied(iss(), &k_b, now), + "pod A must NOT deny missed target k_b — no issuer-wide denial on capacity miss" + ); + // Pod A must NOT deny an unrelated key. + assert!( + !map_a.is_denied(iss(), &k_unrelated, now), + "pod A must NOT deny unrelated key after capacity miss" + ); + + // Pod B map: has k_b, receives k_a cross-pod. + let map_b = make_map(&k_b); + let result_b = map_b.merge_cross_pod_deny(iss(), &k_a, until, now); + assert_eq!( + result_b, + CrossPodMergeResult::CapacityExceeded, + "cross-delivery of k_a to pod B (capacity=1, already holds k_b) must return CapacityExceeded" + ); + assert!( + map_b.is_denied(iss(), &k_b, now), + "pod B must still deny k_b after capacity miss" + ); + assert!( + !map_b.is_denied(iss(), &k_a, now), + "pod B must NOT deny missed target k_a" + ); + + // At exact equality with the TTL both entries are admitted (now < until fails). + assert!( + !map_a.is_denied(iss(), &k_a, until), + "k_a must be admitted at exact equality with TTL" + ); + assert!( + !map_b.is_denied(iss(), &k_b, until), + "k_b must be admitted at exact equality with TTL" + ); + + // Delayed already-expired remote entry: may return capacity outcome but + // must not alter the live entry or deny the expired target / unrelated key. + let expired_until = now - Duration::seconds(1); + let map_c = make_map(&k_a); // pre-filled with k_a active + let result_c = map_c.merge_cross_pod_deny(iss(), &k_b, expired_until, now); + // Expired remote entry is treated as a new (already-expired) entry; since + // the shard is at capacity the remote_merge returns CapacityExceeded. + // The live k_a entry must remain; k_b and k_unrelated must not be denied. + assert!( + map_c.is_denied(iss(), &k_a, now), + "live k_a must survive a capacity-miss with expired remote target" + ); + assert!( + !map_c.is_denied(iss(), &k_b, now), + "expired k_b must not be map-denied after capacity miss" + ); + assert!( + !map_c.is_denied(iss(), &k_unrelated, now), + "unrelated key must not be denied after capacity miss with expired remote" + ); + // Confirm the result is CapacityExceeded (expired entry still counts as + // new against a full shard — it has no active existing entry). + assert_eq!( + result_c, + CrossPodMergeResult::CapacityExceeded, + "expired remote against full shard must return CapacityExceeded" + ); + } + + #[test] + fn remote_merge_poisoned_shard_returns_shard_poisoned() { + let iss = "https://poison-remote.example.com"; + let m = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let m_clone = std::sync::Arc::clone(&m); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + + // Poison the shard. + let _ = std::thread::spawn(move || { + let shard_ref = m_clone.shards.get(iss).expect("shard must exist"); + let _guard = shard_ref.lock().expect("lock acquired"); + panic!("intentional poison for remote_merge test"); + }) + .join(); + + assert_eq!( + m.merge_cross_pod_deny(iss, &k, until, Utc::now()), + CrossPodMergeResult::ShardPoisoned, + "poisoned shard must return ShardPoisoned" + ); + } + + // ── Blocker 2: JTI replay-bound boundary tests ──────────────────────────── + + #[test] + fn concurrent_same_issuer_reservations_do_not_exceed_jti_budget() { + use std::sync::Barrier; + + // capacity=2 → JTI ceiling = 4. One pre-denied target key so the + // deny-entry capacity cannot become the limiting factor. + let m = Arc::new(NipFiDenyMap::new( + 2, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 2, + }], + )); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + // Pre-deny one key so all subsequent threads are updates (bypass entry cap). + let pre_key = key(); + m.atomic_reserve_and_insert(iss(), "jti-pre", until, &pre_key, until, now) + .expect("pre-insert"); + + let n_threads: usize = 8; + let barrier = Arc::new(Barrier::new(n_threads)); + let mut handles = Vec::with_capacity(n_threads); + + for i in 0..n_threads { + let m_clone = Arc::clone(&m); + let b = Arc::clone(&barrier); + let k_clone = pre_key; + let jti = format!("concurrent-jti-{i}"); + handles.push(std::thread::spawn(move || { + b.wait(); // release all threads simultaneously + m_clone.atomic_reserve_and_insert(iss(), &jti, until, &k_clone, until, now) + })); + } + + let results: Vec<_> = handles + .into_iter() + .map(|h| h.join().expect("thread must not panic")) + .collect(); + + let successes = results.iter().filter(|r| r.is_ok()).count(); + let capacity_exceeded = results + .iter() + .filter(|r| matches!(r, Err(ReserveError::CapacityExceeded))) + .count(); + + // The pre-insert consumes 1 JTI slot; ceiling is 4; so exactly 3 of the + // concurrent threads succeed (4 - 1 = 3 remaining slots). + assert_eq!( + successes, + 3, + "exactly 3 concurrent successes allowed (4 JTI ceiling - 1 pre-used = 3), got {successes}" + ); + assert_eq!( + successes + capacity_exceeded, + n_threads, + "every result must be Ok or CapacityExceeded" + ); + + // Confirm the shard's JTI count is exactly at the ceiling. + let live_jtis = m.shards.get(iss()).unwrap().lock().unwrap().jtis.len(); + assert_eq!( + live_jtis, 4, + "shard must have exactly 4 live JTIs (ceiling), found {live_jtis}" + ); + } + + #[test] + fn jti_budget_rejection_is_unapplied_and_exact_jti_retries_after_expiry() { + use chrono::TimeZone; + + let cap = 2usize; + // capacity=2 → JTI ceiling=4. Use fixed synthetic timestamps. + let m = NipFiDenyMap::new( + cap, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: cap, + }], + ); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let k = key(); + let deny_deadline = t0 + Duration::seconds(10); // existing deny until t0+10s + + // Fill all 4 JTI slots: + // jti-0: expiry t0+2s (the short one — will expire first) + // jti-1..3: expiry t0+20s + m.atomic_reserve_and_insert( + iss(), + "jti-0", + t0 + Duration::seconds(2), + &k, + deny_deadline, + t0, + ) + .expect("slot 0"); + m.atomic_reserve_and_insert( + iss(), + "jti-1", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 1"); + m.atomic_reserve_and_insert( + iss(), + "jti-2", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 2"); + m.atomic_reserve_and_insert( + iss(), + "jti-3", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 3"); + + // Attempt candidate JTI X with a long deny deadline (t0+30s). + // JTI budget is full (4/4) → must fail with CapacityExceeded. + let x_jti_expiry = t0 + Duration::seconds(25); + let x_deny_deadline = t0 + Duration::seconds(30); + let result = + m.atomic_reserve_and_insert(iss(), "jti-X", x_jti_expiry, &k, x_deny_deadline, t0); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "JTI budget full → CapacityExceeded" + ); + + // jti-X must NOT be recorded in the JTI set. + { + let shard = m.shards.get(iss()).unwrap(); + let guard = shard.lock().unwrap(); + assert!( + !guard.jtis.contains_key("jti-X"), + "jti-X must be absent from JTI set after budget rejection" + ); + // The deny deadline must NOT have been extended (still t0+10s from + // the last successful insert — the max-merge should not have applied X). + let stored_until = *guard.entries.get(&k.to_hex()).unwrap(); + assert_eq!( + stored_until, deny_deadline, + "deny deadline must not be extended by a rejected JTI-budget command" + ); + } + + // At t0+15s: the deny deadline (t0+10s) has passed → key should be admitted. + let t_after_deny = t0 + Duration::seconds(15); + assert!( + !m.is_denied(iss(), &k, t_after_deny), + "key must be admitted at t0+15s (deny deadline t0+10s expired)" + ); + + // Now retry jti-X at t0+3s: jti-0 (expiry t0+2s) has expired, freeing a slot. + // jti-X itself is still valid (expiry t0+25s > t0+3s). + // Retry uses the same deny_deadline=t0+30s. + let t_retry = t0 + Duration::seconds(3); + let result_retry = + m.atomic_reserve_and_insert(iss(), "jti-X", x_jti_expiry, &k, x_deny_deadline, t_retry); + assert!( + result_retry.is_ok(), + "retry of jti-X after jti-0 expired must succeed, got: {result_retry:?}" + ); + + // jti-X is now in the JTI set. + { + let shard = m.shards.get(iss()).unwrap(); + let guard = shard.lock().unwrap(); + assert!( + guard.jtis.contains_key("jti-X"), + "jti-X must be present after successful retry" + ); + } + + // At t0+15s: deny deadline is now t0+30s (max of t0+10s and t0+30s) → denied. + assert!( + m.is_denied(iss(), &k, t_after_deny), + "key must be denied at t0+15s after successful retry (deadline now t0+30s)" + ); + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index 618ee6b0696..686bce2f712 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -544,7 +544,7 @@ impl ProductionJwksSource { }) } - /// **Test-only.** Construct with an injectable clock so tests can advance + /// **Test/dev-only.** Construct with an injectable clock so tests can advance /// `now` past snapshot hard deadlines without wall-clock sleep. #[cfg(test)] pub(crate) fn new_with_clock( @@ -573,6 +573,39 @@ impl ProductionJwksSource { }) } + /// **Test/dev-only.** Directly seed a pre-built JWKS snapshot for `issuer` + /// without making an HTTP request. Used by route integration tests to + /// construct a warmed `ProductionJwksSource` in a hermetic environment. + /// + /// Panics if `issuer` is not registered in the source. + #[cfg(any(test, feature = "dev"))] + pub async fn seed_snapshot_for_test(&self, issuer: &str, jwks: jsonwebtoken::jwk::JwkSet) { + use sha2::{Digest, Sha256}; + let body = serde_json::to_string(&jwks).expect("serialise test JWKS"); + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + let config = self.configs.get(issuer).expect("issuer must be registered"); + let now = (self.now_fn)(); + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); + let hard_deadline = now + + chrono::Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| chrono::Duration::seconds(i64::MAX / 2)); + let key_set = + super::verifier::AssertionKeySet::new(issuer.to_owned(), 1, jwks, hard_deadline) + .expect("valid test JWKS"); + let snapshot = CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }; + let states = self.states.read().await; + let state_mutex = states.get(issuer).expect("issuer must be registered"); + let mut state = state_mutex.lock().await; + state.snapshot = Some(snapshot); + state.generation_counter = 1; + } + async fn fetch_fresh( &self, issuer: &str, diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index ce977090645..2d440bd092e 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -9,8 +9,10 @@ pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; +pub mod command; pub mod config; pub mod denial; +pub mod deny_map; pub mod discovery; pub mod jwks; pub mod startup; @@ -20,12 +22,17 @@ pub use assertion::{ CanonicalCapabilities, ConfidentialAssertion, FederatedIdentity, RevalidationDependencies, VerifiedAssertion, }; +pub use command::{ + CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, CommandVerifier, + COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, +}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use deny_map::{CrossPodMergeResult, DenySetFull, IssuerCapacity, NipFiDenyMap}; pub use discovery::{ AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, }; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index cf20b57a86e..8425299cf20 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -143,6 +143,15 @@ impl AssertionKeySet { pub(crate) fn hard_deadline(&self) -> chrono::DateTime { self.hard_deadline } + + /// The authenticated JWKS for this issuer snapshot. + /// + /// `pub(super)` so the command verifier in the same `nip_fi` module can + /// look up keys by `kid` without duplicating the key-selection logic. + /// External consumers cannot access key material through this path. + pub(super) fn jwks(&self) -> &JwkSet { + &self.jwks + } } impl fmt::Debug for AssertionKeySet { @@ -566,10 +575,10 @@ impl VerifierError { } /// A minimally parsed JOSE header. -struct ParsedHeader { - algorithm: Algorithm, - kid: String, - typ: Option, +pub(super) struct ParsedHeader { + pub(super) algorithm: Algorithm, + pub(super) kid: String, + pub(super) typ: Option, } /// Reject any token that is not exactly three compact-JWS segments. @@ -582,7 +591,7 @@ struct ParsedHeader { /// base64url — is validated separately by [`enforce_signature_shape`] after /// header parsing, so that no structurally malformed token can defer to the /// key-source lookup and masquerade as a 503 outage (NIP-FI.md:151-171). -fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { +pub(super) fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { if token.split('.').count() == 3 { Ok(()) } else { @@ -599,7 +608,7 @@ fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { /// after [`parse_header`], so `alg=none`'s empty-signature token is already /// rejected at header parsing (unsupported algorithm) before this distinction /// matters (NIP-FI.md:151-171). -fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { +pub(super) fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { let signature = token .split('.') .nth(2) @@ -608,7 +617,7 @@ fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { base64url_decode(signature).map(|_| ()) } -fn parse_header(token: &str) -> Result { +pub(super) fn parse_header(token: &str) -> Result { let segment = token .split('.') .next() @@ -758,7 +767,7 @@ fn capture_capabilities( CanonicalCapabilities::from_pairs(entries) } -fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, VerifierError> { +pub(super) fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, VerifierError> { let mut matching = jwks .keys .iter() @@ -770,7 +779,7 @@ fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, Verifie Ok(jwk) } -fn validate_jwk(jwk: &Jwk, token_algorithm: Algorithm) -> Result<(), VerifierError> { +pub(super) fn validate_jwk(jwk: &Jwk, token_algorithm: Algorithm) -> Result<(), VerifierError> { let usage_ok = jwk .common .public_key_use @@ -882,7 +891,7 @@ fn optional_numeric_date( /// them) is converted with subsecond nanosecond precision. NaN, infinity, a /// non-number, and any magnitude outside the representable `i64`-seconds range /// deny as invalid time bounds. -fn parse_numeric_date(value: &Value) -> Result, VerifierError> { +pub(super) fn parse_numeric_date(value: &Value) -> Result, VerifierError> { // Integer NumericDate: exact, no float round-trip. if let Some(secs) = value.as_i64() { return Utc @@ -926,7 +935,7 @@ fn checked_add(at: DateTime, delta: chrono::Duration) -> Result Result, VerifierError> { +pub(super) fn parse_unique_claims(token: &str) -> Result, VerifierError> { let segment = token .split('.') .nth(1) diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 150c8f47682..f72cdbc7314 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -1169,6 +1169,16 @@ impl Db { } } + /// Return a reference to the writer pool. + /// + /// Callers that need a pool handle for standalone free functions (e.g., + /// `buzz_db::insert_mentions`) can use this. Prefer the `Db` method + /// equivalents when they exist; use `pool()` only for functions that have + /// no `Db` wrapper yet. + pub fn pool(&self) -> &PgPool { + &self.pool + } + /// Refresh all expected operation-specific waiter gauges, including zero. /// /// The relay pool sampler calls this periodically so an exporter idle diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 7a246e04698..7312bf3c015 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -197,6 +197,82 @@ async fn acquire_channel_membership_lock( Ok(()) } +// ── Transaction-level membership helpers (for commit_participant_join) ──────── + +/// Acquire the per-channel membership advisory lock on a caller-owned transaction. +/// +/// Equivalent to the internal `acquire_channel_membership_lock`, but exposed +/// for callers that need to compose multiple operations in one transaction +/// (e.g., `commit_participant_join` in `audio/handler.rs`). +pub async fn acquire_channel_membership_lock_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { + acquire_channel_membership_lock(tx, community_id, channel_id).await +} + +/// Check whether a pubkey is an active channel member on a caller-owned transaction. +/// +/// Runs the same query as `is_member` but within the caller's transaction so +/// the read is serialized with any concurrent membership writes on the same lock. +pub async fn is_member_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM channel_members cm \ + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .fetch_one(&mut **tx) + .await?; + let cnt: i64 = row.try_get("cnt")?; + Ok(cnt > 0) +} + +/// Auto-add a member on a caller-owned transaction (for ephemeral-channel admission). +/// +/// Inserts or reactivates the membership row at `Member` role with the given +/// `invited_by` (channel creator for huddle auto-add). Does NOT acquire the +/// advisory lock — callers must have already called +/// `acquire_channel_membership_lock_in_transaction` before calling this. +/// +/// Used by `commit_participant_join` to atomically add membership and the +/// `48101` event in a single transaction under a session effect permit. +pub async fn insert_auto_membership_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + invited_by: &[u8], +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'member'::member_role, $4) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + removed_at = NULL, + removed_by = NULL, + role = EXCLUDED.role + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .bind(invited_by) + .execute(&mut **tx) + .await?; + Ok(()) +} + +// ── End transaction-level helpers ───────────────────────────────────────────── + /// An active member roster captured while holding the channel's membership /// serialization lock on one writer connection. pub struct LockedMemberSnapshot { diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 542a6d80fab..7ef40f2d81b 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -346,6 +346,59 @@ async fn huddle_started_link_exists_with_operation( .any(|content| huddle_started_content_links(content, ephemeral_channel_id))) } +/// Return whether a creator-signed huddle-start event links a parent channel +/// to the requested ephemeral huddle channel — checked inside an open +/// transaction with a shared row lock on matching rows. +/// +/// Uses `SELECT ... FOR SHARE` so any concurrent `soft_delete_event()` that +/// attempts `UPDATE events SET deleted_at = NOW() WHERE ...` on the same row +/// must wait until this transaction commits or rolls back. This makes the +/// re-read authoritative against concurrent deletion — "visibility" alone +/// (i.e. a plain SELECT) is insufficient under READ COMMITTED because deletion +/// can commit between the SELECT and the join commit in the same transaction. +/// +/// Uses `tx.as_mut()` so the lock participates in the caller's transaction. +/// A `false` return means the link was deleted or was never inserted, and the +/// caller should abort the surrounding transaction. +pub async fn huddle_started_link_exists_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], +) -> Result { + let uuid_needle = format!("%{}%", ephemeral_channel_id); + let candidates: Vec = sqlx::query_scalar( + r#" + SELECT content + FROM events + WHERE deleted_at IS NULL + AND community_id = $1 + AND channel_id = $2 + AND kind = $3 + AND pubkey = $4 + AND octet_length(content) <= $5 + AND content ILIKE $6 + ORDER BY created_at DESC, id ASC + LIMIT $7 + FOR SHARE + "#, + ) + .bind(community_id.as_uuid()) + .bind(parent_channel_id) + .bind(KIND_HUDDLE_STARTED as i32) + .bind(creator_pubkey) + .bind(HUDDLE_LINK_CONTENT_MAX_BYTES) + .bind(uuid_needle) + .bind(HUDDLE_LINK_CANDIDATE_LIMIT) + .fetch_all(tx.as_mut()) + .await?; + + Ok(candidates + .iter() + .any(|content| huddle_started_content_links(content, ephemeral_channel_id))) +} + /// Insert a Nostr event. Rejects AUTH and ephemeral kinds. /// /// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate. @@ -3086,6 +3139,136 @@ mod postgres_tests { ); } + // I4 deletion-race witness: + // `huddle_started_link_exists_in_transaction` acquires FOR SHARE on the + // matching row. A concurrent `soft_delete_event` (UPDATE events SET + // deleted_at = NOW() WHERE ...) must BLOCK until the join transaction + // commits or rolls back — it cannot race past the re-read and commit + // deletion before the join completes. + // + // Test protocol: + // 1. Insert a huddle_started event row. + // 2. Open a transaction and call `huddle_started_link_exists_in_transaction` + // (acquires FOR SHARE). + // 3. Concurrently try `soft_delete_event` from a second connection — + // the UPDATE blocks because FOR SHARE conflicts with UPDATE. + // 4. Commit the first transaction. + // 5. The concurrent delete now completes — confirm it succeeds. + // + // Mutation evidence: + // Remove `FOR SHARE` from the SELECT in `huddle_started_link_exists_in_transaction` → + // the concurrent delete completes before the join tx commits → + // `link_gone_before_commit` becomes true before the tx commits → + // assertion panics ("FOR SHARE must make delete block"). + #[tokio::test] + #[ignore = "requires Postgres — link deletion contends with join transaction via FOR SHARE"] + async fn i4_huddle_link_deletion_blocked_by_join_transaction_for_share() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use tokio::sync::Notify; + + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let community_id = buzz_core::CommunityId::from_uuid(community); + let parent = make_test_channel(&pool, community, None).await; + let session = make_test_channel(&pool, community, None).await; + let creator = vec![0xAAu8; 32]; + let event_id = vec![0xBBu8; 32]; + + // Insert the huddle_started event row. + let content = serde_json::json!({"ephemeral_channel_id": session.to_string()}).to_string(); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7)", + ) + .bind(community) + .bind(&event_id) + .bind(&creator) + .bind(KIND_HUDDLE_STARTED as i32) + .bind(&content) + .bind(vec![0u8; 64]) + .bind(parent) + .execute(&pool) + .await + .expect("insert huddle_started event"); + + // Signal: join transaction has acquired FOR SHARE, delete may attempt. + let delete_may_start = Arc::new(Notify::new()); + // Signal: delete completed (or timed out). + let delete_completed = Arc::new(AtomicBool::new(false)); + let link_gone_before_commit = Arc::new(AtomicBool::new(false)); + + let delete_may_start2 = delete_may_start.clone(); + let delete_completed2 = delete_completed.clone(); + let link_gone2 = link_gone_before_commit.clone(); + let pool2 = pool.clone(); + let event_id2 = event_id.clone(); + let community2 = community_id; + + // Spawn the deleter: waits for the join tx to hold FOR SHARE, then tries + // to delete. It should block until the join tx commits. + let delete_handle = tokio::spawn(async move { + delete_may_start2.notified().await; + // Record whether the link row is still live at delete time. + // Under FOR SHARE this call will block until the join tx commits. + let result = + soft_delete_event_and_update_thread(&pool2, community2, &event_id2, None, None) + .await + .expect("soft_delete_event should not error"); + // Mark whether the link was deleted (not already gone). + link_gone2.store(result, Ordering::Relaxed); + delete_completed2.store(true, Ordering::Relaxed); + }); + + // Open the join transaction and acquire FOR SHARE. + let mut tx = pool.begin().await.expect("begin join tx"); + let exists = huddle_started_link_exists_in_transaction( + &mut tx, + community_id, + parent, + session, + &creator, + ) + .await + .expect("huddle_started_link_exists_in_transaction"); + assert!(exists, "I4: link must exist before commit"); + + // Signal the deleter to attempt its UPDATE now. + delete_may_start.notify_one(); + + // Give the deleter a brief window to attempt the DELETE. Under correct + // FOR SHARE locking, it blocks here and `delete_completed` stays false. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + assert!( + !delete_completed.load(Ordering::Relaxed), + "I4: FOR SHARE must make soft_delete_event block — \ + delete completed before the join transaction committed, \ + which proves deletion can race past the re-read. \ + Remove FOR SHARE from the SELECT in \ + huddle_started_link_exists_in_transaction to reproduce." + ); + + // Commit the join transaction — delete should unblock. + tx.commit().await.expect("commit join tx"); + + tokio::time::timeout(std::time::Duration::from_secs(5), delete_handle) + .await + .expect("I4: delete must complete within 5s after join tx commit") + .expect("delete_handle must not panic"); + + // After the join tx commits, the delete should have succeeded. + assert!( + link_gone_before_commit.load(Ordering::Relaxed), + "I4: soft_delete_event must succeed once the join tx releases FOR SHARE" + ); + assert!( + delete_completed.load(Ordering::Relaxed), + "I4: delete must complete after join tx commit" + ); + } + #[test] fn huddle_started_content_requires_matching_ephemeral_field() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-pubsub/src/conn_control.rs b/crates/buzz-pubsub/src/conn_control.rs index bc177cff139..875bfd805c0 100644 --- a/crates/buzz-pubsub/src/conn_control.rs +++ b/crates/buzz-pubsub/src/conn_control.rs @@ -34,6 +34,55 @@ pub fn conn_control_channel(ctx: &TenantContext) -> String { format!("{BUZZ_PREFIX}:{}:{CONN_CONTROL_SUFFIX}", ctx.community()) } +// ── NIP-FI global disconnect channel ───────────────────────────────────────── + +/// Global (issuer-scoped, not community-scoped) Redis pub/sub channel for NIP-FI +/// disconnect commands. A single channel covers all communities because NIP-FI +/// deny entries apply across the full issuer domain — the community a user is +/// connected to at that moment is irrelevant. +pub const NIP_FI_DISCONNECT_CHANNEL: &str = "buzz:nip-fi:disconnect"; + +/// A NIP-FI admin-disconnect command broadcast cross-pod after the local deny +/// entry is inserted. Every pod merges this entry into its own deny map and +/// closes any matching sessions (same `max(until)` rule as the local path). +/// +/// Transmitted asynchronously — the HTTP response does not wait on remote-pod +/// delivery; the spec's asynchronous-success semantics are preserved. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NipFiDisconnect { + /// Exact `iss` URI of the issuer that originated the command. + pub issuer: String, + /// 32 raw bytes of the target Nostr public key. + pub pubkey_bytes: Vec, + /// `until` seconds since the Unix epoch (whole-second component). + pub until_unix: i64, + /// Nanosecond sub-second component of `until` (0..1_000_000_000). + /// Transmitted alongside `until_unix` so the full sub-second precision of + /// the signed command JWT is preserved across pod boundaries. + #[serde(default)] + pub until_unix_nanos: u32, +} + +/// Encode a [`NipFiDisconnect`] message to a JSON string for publication on +/// the Redis pub/sub channel. +/// +/// This is the single publication path — the HTTP handler and any future +/// publisher must call this function rather than serialising directly, so the +/// wire format is defined in one place and the round-trip oracle can cover it. +pub fn encode_nip_fi_disconnect(message: &NipFiDisconnect) -> Result { + serde_json::to_string(message) +} + +/// Decode a [`NipFiDisconnect`] message from a JSON string received from the +/// Redis pub/sub channel. +/// +/// This is the single consumption path — the subscriber and any future consumer +/// must call this function rather than deserialising directly, so the wire format +/// is defined in one place and the round-trip oracle can cover it. +pub fn decode_nip_fi_disconnect(payload: &str) -> Result { + serde_json::from_str(payload) +} + /// Parse a connection-control Redis channel into its scoped community id. pub fn parse_conn_control_channel(channel: &str) -> Option { let mut parts = channel.split(':'); @@ -161,6 +210,76 @@ async fn connect_and_subscribe( Ok(()) } +// ── NIP-FI disconnect subscriber ────────────────────────────────────────────── + +/// Subscribes to [`NIP_FI_DISCONNECT_CHANNEL`] and forwards commands to the +/// broadcast. Mirrors [`run_conn_control_subscriber`]: reconnect loop with +/// exponential backoff. Never returns. +pub async fn run_nip_fi_disconnect_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + + loop { + match connect_and_subscribe_nip_fi(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis NIP-FI disconnect stream ended (clean disconnect) — reconnecting in {backoff_secs}s" + ); + } + Err(e) => { + tracing::error!( + "Redis NIP-FI disconnect error: {e} — reconnecting in {backoff_secs}s" + ); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + } +} + +async fn connect_and_subscribe_nip_fi( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut conn = client.get_async_pubsub().await?; + + conn.subscribe(NIP_FI_DISCONNECT_CHANNEL).await?; + + tracing::info!( + "Redis NIP-FI disconnect subscriber connected — listening on {NIP_FI_DISCONNECT_CHANNEL}" + ); + + let mut stream = conn.on_message(); + while let Some(msg) = stream.next().await { + let payload: String = match msg.get_payload() { + Ok(p) => p, + Err(e) => { + tracing::warn!("Failed to get NIP-FI disconnect payload: {e}"); + continue; + } + }; + + let command: NipFiDisconnect = match decode_nip_fi_disconnect(&payload) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to deserialize NIP-FI disconnect message: {e}"); + continue; + } + }; + + if broadcast_tx.send(command).is_err() { + tracing::trace!("No NIP-FI disconnect receivers — message dropped"); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -226,4 +345,55 @@ mod tests { let json = serde_json::to_string(&cmd).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); } + + // ── NipFiDisconnect serde ───────────────────────────────────────────────── + + #[test] + fn nip_fi_disconnect_serde_round_trips() { + let cmd = NipFiDisconnect { + issuer: "https://idp.example.com".to_string(), + pubkey_bytes: vec![0xabu8; 32], + until_unix: 9_999_999_999, + until_unix_nanos: 500_000_000, + }; + let json = serde_json::to_string(&cmd).unwrap(); + let decoded: NipFiDisconnect = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, cmd); + } + + #[test] + fn nip_fi_disconnect_nanos_default_to_zero_when_absent() { + // Old messages (without the until_unix_nanos field) must still deserialize. + let legacy_json = r#"{"issuer":"https://idp.example.com","pubkey_bytes":[171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171],"until_unix":9999999999}"#; + let decoded: NipFiDisconnect = serde_json::from_str(legacy_json).unwrap(); + assert_eq!( + decoded.until_unix_nanos, 0, + "missing nanos field must default to 0" + ); + assert_eq!(decoded.until_unix, 9_999_999_999); + } + + #[test] + fn nip_fi_disconnect_channel_is_global_not_community_scoped() { + // Must NOT contain a community UUID segment — it's issuer-global. + assert_eq!(NIP_FI_DISCONNECT_CHANNEL, "buzz:nip-fi:disconnect"); + assert!(!NIP_FI_DISCONNECT_CHANNEL.contains("conn-control")); + } + + #[test] + fn nip_fi_disconnect_malformed_payload_is_skipped() { + // Simulate what the subscriber does: malformed JSON produces an error + // and the message is skipped (no panic). + let malformed = r#"{"issuer": 42}"#; // wrong type for issuer + assert!(serde_json::from_str::(malformed).is_err()); + // Well-formed payload still parses. + let good = serde_json::to_string(&NipFiDisconnect { + issuer: "https://a.example.com".to_string(), + pubkey_bytes: vec![1u8; 32], + until_unix: 1_000_000, + until_unix_nanos: 0, + }) + .unwrap(); + assert!(serde_json::from_str::(&good).is_ok()); + } } diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 4f1690beefb..0240af66484 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -54,7 +54,9 @@ use tokio::sync::{broadcast, mpsc, Mutex}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; +pub use crate::conn_control::NipFiDisconnect; use crate::conn_control::{conn_control_channel, ConnControl, ScopedConnControl}; +pub use crate::conn_control::{decode_nip_fi_disconnect, encode_nip_fi_disconnect}; pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey}; /// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers. @@ -110,6 +112,7 @@ pub struct PubSubManager { broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, conn_control_tx: broadcast::Sender, + nip_fi_disconnect_tx: broadcast::Sender, } impl PubSubManager { @@ -126,6 +129,7 @@ impl PubSubManager { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); let (conn_control_tx, _) = broadcast::channel(4096); + let (nip_fi_disconnect_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); Ok(Self { @@ -138,6 +142,7 @@ impl PubSubManager { broadcast_tx, cache_invalidation_tx, conn_control_tx, + nip_fi_disconnect_tx, }) } @@ -180,6 +185,19 @@ impl PubSubManager { .await; } + /// Starts the NIP-FI disconnect subscriber loop with automatic + /// reconnection. Runs forever — spawn this in a background task. + /// + /// Every pod subscribes to this global channel; on receipt it merges the + /// deny entry and closes matching local sessions. + pub async fn run_nip_fi_disconnect_subscriber(self: Arc) { + conn_control::run_nip_fi_disconnect_subscriber( + self.redis_url.clone(), + self.nip_fi_disconnect_tx.clone(), + ) + .await; + } + /// Returns a new broadcast receiver for locally-published channel events. pub fn subscribe_local(&self) -> broadcast::Receiver { self.broadcast_tx.subscribe() @@ -265,6 +283,11 @@ impl PubSubManager { self.conn_control_tx.subscribe() } + /// Returns a new broadcast receiver for cross-pod NIP-FI disconnect commands. + pub fn subscribe_nip_fi_disconnect(&self) -> broadcast::Receiver { + self.nip_fi_disconnect_tx.subscribe() + } + /// Publish a cache-key drop to all pods. Fire-and-forget at the call site: /// the local cache is already dropped synchronously; this carries the same /// drop cross-pod. A dropped publish is backstopped by the REQ denial-path @@ -304,6 +327,26 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a NIP-FI disconnect command to all pods on the global channel. + /// + /// Called after the local deny entry is inserted. Remote pods receive this + /// and apply the same `max(until)` merge + all-community session close. + /// Fire-and-forget: the HTTP response does not wait on delivery. + pub async fn publish_nip_fi_disconnect( + &self, + command: &NipFiDisconnect, + ) -> Result { + use crate::conn_control::{encode_nip_fi_disconnect, NIP_FI_DISCONNECT_CHANNEL}; + let mut conn = self.pool.get().await?; + let payload = encode_nip_fi_disconnect(command)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(NIP_FI_DISCONNECT_CHANNEL) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) + } + /// Publish an event to the Redis channel. Returns subscriber count. /// /// Routing note (NIP-ER author-private reminders): events are keyed by diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 39f8b26969f..c62aa18ca33 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -39,6 +39,7 @@ tower-http = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +jsonwebtoken = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-opentelemetry = { workspace = true } @@ -90,8 +91,9 @@ dev = ["buzz-auth/dev"] # dev-dependency feeds every target of its package, so keeping the mesh-llm # SDK here forced the entire mesh-llm/skippy/rmcp tree to compile before any # relay unit test could build — the Desktop E2E Relay 30m timeout. +jsonwebtoken = { workspace = true, features = ["use_pem"] } buzz-core = { workspace = true, features = ["test-utils"] } -buzz-auth = { workspace = true, features = ["dev"] } +buzz-auth = { workspace = true, features = ["dev", "test-utils"] } reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index a891b282c3f..defdf7b54cf 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1549,7 +1549,12 @@ mod postgres_tests { } async fn disabled_mode_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test_with_db_from_env: NIP-FI env isolation preserved; + // database_url overridden from CI/local-dev env chain so postgres_tests + // that seed rows via database_url() match the pool the handler queries. + // Falls back to the port-1 stub when neither env var is set (unit tests + // that do not touch the DB continue to fail-closed correctly). [F6, admin-DB] + let mut config = crate::config::Config::hermetic_for_test_with_db_from_env(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.admin = Some(crate::config::AdminConfig { @@ -2158,7 +2163,7 @@ mod postgres_tests { /// Build an AppState that uses a real Postgres connection pool so HTTP /// routes that hit the DB can commit and read back results. async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = vec![test_operator_keys().public_key().to_hex()]; @@ -2914,7 +2919,12 @@ mod postgres_tests { pubkeys: Vec, replay: Arc, ) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test_with_db_from_env: NIP-FI env isolation preserved; + // database_url overridden from CI/local-dev env chain so postgres_tests + // that seed rows via database_url() (e.g. unban/untimeout tests) match + // the pool the admin HTTP routes query. Fails closed to port-1 when no + // env var is set. [F6, admin-DB] + let mut config = crate::config::Config::hermetic_for_test_with_db_from_env(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); // Populate relay_operator_pubkeys so resolve_admin_principal can grant @@ -3412,7 +3422,7 @@ mod postgres_tests { #[tokio::test] async fn probe_in_nip98_mode_with_owner_fallback_b_returns_operator_role() { let owner_keys = nostr::Keys::generate(); - let mut config = crate::config::Config::from_env().expect("default config"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); // Empty operator list — activates fallback B. @@ -3489,7 +3499,7 @@ mod postgres_tests { let owner_hex = owner_keys.public_key().to_hex(); let owner_bytes = owner_keys.public_key().to_bytes().to_vec(); - let mut config = crate::config::Config::from_env().expect("default config"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = vec![]; // activates owner fallback B @@ -3616,7 +3626,10 @@ mod postgres_tests { // Inject RELAY_OWNER_PUBKEY into the state config manually. // We need a fresh state with both set. - let mut config = crate::config::Config::from_env().expect("default config"); + // hermetic_for_test_with_db_from_env: this test does a real DB lookup for + // resolve_admin_principal (owner fallback path); the pool must point at the + // live test DB, not the port-1 hermetic stub. [F6, admin-DB, probe-owner] + let mut config = crate::config::Config::hermetic_for_test_with_db_from_env(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = vec![other_operator.public_key().to_hex()]; @@ -3986,7 +3999,7 @@ mod postgres_tests { let target_keys = nostr::Keys::generate(); let target_hex = target_keys.public_key().to_hex(); // Put target in config — makes it config-backed and immutable. - let mut config = crate::config::Config::from_env().expect("default config"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = @@ -4059,7 +4072,7 @@ mod postgres_tests { let operator_keys = nostr::Keys::generate(); let target_keys = nostr::Keys::generate(); let target_hex = target_keys.public_key().to_hex(); - let mut config = crate::config::Config::from_env().expect("default config"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = @@ -4130,7 +4143,7 @@ mod postgres_tests { // Owner fallback B: RELAY_OPERATOR_PUBKEYS empty, owner key is implicit operator. let owner_keys = nostr::Keys::generate(); let owner_hex = owner_keys.public_key().to_hex(); - let mut config = crate::config::Config::from_env().expect("default config"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = vec![]; // activates fallback B @@ -5838,7 +5851,7 @@ mod postgres_tests { /// Build an AppState wired to the given pool. Used by the e2e driver tests so /// they share the same DB connection the test fixtures wrote to. async fn state_from_pool(pool: sqlx::PgPool) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.admin = Some(crate::config::AdminConfig { @@ -7454,16 +7467,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager @@ -9142,16 +9160,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager @@ -9407,16 +9430,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager @@ -10282,16 +10310,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager @@ -10542,16 +10575,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager @@ -10740,16 +10778,21 @@ mod postgres_tests { let conn_id = uuid::Uuid::new_v4(); let (tx, _rx) = tokio::sync::mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = tokio::sync::mpsc::channel(1); + let reg_cancel = tokio_util::sync::CancellationToken::new(); + let reg_control = crate::state::CommunityConnectionControl::new(reg_cancel.clone()); state.conn_manager.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, - tokio_util::sync::CancellationToken::new(), + reg_cancel, cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + reg_control, ); state .conn_manager diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c29df6746bb..86b76b2d260 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -359,7 +359,9 @@ mod tests { use tower::ServiceExt; async fn unconfigured_test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("test config"); + // F6: use hermetic_for_test — never calls Config::from_env, so NIP-FI + // env-var mutations in concurrent tests cannot race this reader. + let mut config = crate::config::Config::hermetic_for_test(); config.klipy = None; config.redis_url = "redis://127.0.0.1:1".to_string(); diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 780532ec5d0..d6fee763d85 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -1131,7 +1131,15 @@ mod tests { } async fn test_state() -> Arc { + // Hold ENV_TEST_MUTEX while reading Config::from_env() — same mutex used by + // NIP-FI config parser tests. Prevents a parallel nip_fi_config test that + // installs invalid permissive/enforce-without-issuers env vars from causing + // Config::from_env() to panic here mid-parse. [F6: hermetic env reader] + let _env_lock = crate::config::ENV_TEST_MUTEX + .lock() + .unwrap_or_else(|e| e.into_inner()); let mut config = crate::config::Config::from_env().expect("default config loads"); + drop(_env_lock); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e59..f8ffef0ec9d 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod invites; pub mod media; pub mod mesh_demo; pub mod nip05; +pub mod nip_fi; pub mod operator; pub mod workflows; diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs new file mode 100644 index 00000000000..2cf50c622bb --- /dev/null +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -0,0 +1,2265 @@ +//! NIP-FI admin disconnect endpoint — `POST /api/nip-fi/disconnect`. +//! +//! This module owns: +//! +//! * [`disconnect`] — the axum handler for `POST /api/nip-fi/disconnect`. +//! * [`build_nip_fi_command_components`] — startup initialization called by +//! `main.rs` to wire the deny map and command verifier into `AppState`. +//! +//! ## Transport invariant +//! +//! The NIP-FI admin API is **not** a protected HTTP surface. It MUST NOT be +//! subjected to the NIP-FI HTTP-ingress admission procedure. It carries +//! `Nostr-Federated-Identity` for a command JWS, not an identity assertion. +//! [NIP-FI.md §HTTP ingress, protected surfaces note] +//! +//! Authentication is entirely by the signed command JWT verified inside +//! [`buzz_auth::CommandVerifier::verify`]; no NIP-98 or relay-membership check +//! is performed. +//! +//! ## Environment variables +//! +//! The command API is enabled when `BUZZ_NIP_FI_MODE=enforce` and the issuer +//! JSON entries include the S4 fields. S4 fields are read from the same +//! `BUZZ_NIP_FI_ISSUERS` JSON array; each issuer entry optionally carries: +//! +//! ```json +//! { +//! "maximum_command_age_seconds": 30, +//! "authorized_principals": ["service-account@issuer.example.com"], +//! "deny_set_capacity": 50000 +//! } +//! ``` +//! +//! `maximum_command_age_seconds` and `authorized_principals` are required in +//! enforce mode if any issuer is command-capable. `deny_set_capacity` defaults +//! to [`DEFAULT_DENY_SET_CAPACITY`] when absent. + +use std::sync::Arc; + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, Response, StatusCode}, +}; +use serde::Deserialize; +use tracing::{debug, warn}; + +use buzz_auth::{ + CommandError, CommandIssuerPolicy, CommandVerifier, IssuerCapacity, NipFiDenyMap, NipFiMode, + ProductionJwksSource, CLIENT_ATTACHED_HEADER, +}; + +use crate::state::AppState; + +/// Default per-issuer deny-set capacity when `deny_set_capacity` is absent. +/// 50_000 entries × ~128 bytes ≈ 6.4 MB per issuer. +pub const DEFAULT_DENY_SET_CAPACITY: usize = 50_000; + +// ── Request / response shapes ──────────────────────────────────────────────── + +/// JSON body for `POST /api/nip-fi/disconnect`. +#[derive(Debug, Deserialize)] +pub struct DisconnectRequest { + /// Lowercase hex encoding of the 32-byte target Nostr public key. + pub pubkey: String, +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +/// `POST /api/nip-fi/disconnect` +/// +/// Executes `VerifyCommandJwt`, inserts the deny entry, and closes all live +/// sessions for the target pubkey across all communities. +/// +/// Response contract (from NIP-FI spec): +/// +/// | Condition | Status | Body | +/// |---|---|---| +/// | Authorized; action taken or no-op | `200` | `{"disconnected":true}` | +/// | Missing or invalid command JWT | `401`/`403` | per rejection table | +/// | Malformed request body or `until` exceeds ceiling | `400` | `"bad request\n"` | +/// | Deny set at capacity | `503` | `"deny set full\n"` | +/// +/// The endpoint is NOT a protected HTTP surface. [NIP-FI.md §HTTP ingress] +pub async fn disconnect( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + // ── Extract the command JWT from the header ──────────────────────────── + let token = match extract_command_jwt(&headers) { + Ok(t) => t, + Err(status) => { + return if status == StatusCode::UNAUTHORIZED { + // [NIP-FI.md §Rejection table]: 401 MUST carry WWW-Authenticate: Nostr. + auth_required_response() + } else { + plain_response(status, "evidence rejected\n") + }; + } + }; + + // ── Parse the JSON body ─────────────────────────────────────────────── + let req: DisconnectRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"), + }; + + // body.pubkey must be lowercase hex of exactly 32 bytes. + let body_pubkey = match parse_hex_pubkey(&req.pubkey) { + Some(k) => k, + None => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"), + }; + + // ── Command verifier ────────────────────────────────────────────────── + let verifier = match &state.nip_fi_command_verifier { + Some(v) => v.clone(), + None => { + // Mode is Off or not yet initialized. + debug!("nip-fi disconnect: no command verifier configured"); + return plain_response( + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + ); + } + }; + + let result = verifier.verify(token, "POST", "/api/nip-fi/disconnect", &body_pubkey); + + match result { + Ok(cmd) => { + // ── Deny entry inserted; close sessions synchronously ───────── + let pubkey_bytes = cmd.target_pubkey.to_bytes(); + // Issuer-scoped: the deny entry is keyed by (caller_iss, k), so only + // sessions admitted under caller_iss are closed. [FI-TRACE-DENY-SET] + let closed = state + .conn_manager + .disconnect_nip_fi(&cmd.caller_iss, &pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(&cmd.caller_iss, &pubkey_bytes); + if closed > 0 { + // [FI-TRACE-PRIVACY-NONPUBLIC]: raw `iss` MUST NOT appear in + // logs, metrics, or traces. Log only a count. + debug!(closed, "nip-fi disconnect: closed sessions"); + } + metrics::counter!("buzz_nip_fi_disconnect_total").increment(1); + metrics::counter!( + "buzz_nip_fi_sessions_closed_total", + "reason" => "admin_disconnect" + ) + .increment(closed as u64); + + // Cross-pod propagation: publish to global NIP-FI Redis channel + // so remote pods can merge the deny entry and close their sessions. + // Asynchronous: HTTP response does not wait on remote delivery. + { + let pubsub = Arc::clone(&state.pubsub); + let msg = nip_fi_disconnect_message(&cmd); + tokio::spawn(async move { + if let Err(e) = pubsub.publish_nip_fi_disconnect(&msg).await { + // [FI-TRACE-PRIVACY-NONPUBLIC]: no iss or pubkey in logs + tracing::warn!("nip-fi: cross-pod propagation publish failed: {e}"); + metrics::counter!("buzz_nip_fi_disconnect_propagation_failures_total") + .increment(1); + } + }); + } + + disconnected_response() + } + Err(CommandError::DenySetFull) => { + warn!("nip-fi disconnect: deny set full — command rejected, no sessions closed"); + metrics::counter!("buzz_nip_fi_disconnect_capacity_rejections_total").increment(1); + plain_response(StatusCode::SERVICE_UNAVAILABLE, "deny set full\n") + } + Err(CommandError::UntilExceedsCeiling) | Err(CommandError::MalformedRequest) => { + plain_response(StatusCode::BAD_REQUEST, "bad request\n") + } + Err(CommandError::AuthorizationUnavailable) => plain_response( + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + ), + Err(CommandError::EvidenceRejected) => { + plain_response(StatusCode::FORBIDDEN, "evidence rejected\n") + } + Err(CommandError::AuthorizationDenied) => { + plain_response(StatusCode::FORBIDDEN, "authorization denied\n") + } + } +} + +// ── Startup component builder ───────────────────────────────────────────────── + +/// Per-issuer command configuration parsed from the `BUZZ_NIP_FI_ISSUERS` JSON. +/// +/// Added to each entry in S4. All three fields are optional (absent = +/// command API disabled for that issuer / default capacity used). +#[derive(Debug, Default, Clone, serde::Deserialize)] +pub struct CommandIssuerEnvConfig { + /// Positive seconds, ≤ 60. Required for the command API to be enabled. + pub maximum_command_age_seconds: Option, + /// Non-empty list of authorized `sub` values. Required if command age is set. + pub authorized_principals: Option>, + /// Hard ceiling on live deny entries for this issuer. + /// Defaults to [`DEFAULT_DENY_SET_CAPACITY`] when absent. + pub deny_set_capacity: Option, +} + +/// The `NipFiDenyMap` + `CommandVerifier` pair built at startup. +pub struct NipFiCommandComponents { + /// The shared deny map consumed by WS admission and S5 HTTP admission. + pub deny_map: Arc, + /// The command verifier for the `POST /api/nip-fi/disconnect` endpoint. + pub command_verifier: Arc>>, +} + +/// Outcome of applying a cross-pod NIP-FI disconnect message. +/// +/// Returned by [`apply_nip_fi_disconnect`]; used by the `main.rs` receive loop +/// and by tests to assert the production decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NipFiDisconnectApplyResult { + /// Command API is not enabled on this pod (deny map absent); message ignored. + Disabled, + /// Message was rejected before reaching the map (invalid pubkey, unknown + /// issuer, unrepresentable timestamp, or ceiling exceeded). + Rejected, + /// Message was applied; carries the map's merge result. + Applied(buzz_auth::CrossPodMergeResult), +} + +/// Build the [`buzz_pubsub::NipFiDisconnect`] bus message from a successfully +/// verified command. +/// +/// This is the single publisher mapping — the HTTP success path calls this +/// function to ensure the nanos precision is always captured correctly. +/// Reverting either the seconds or the nanos field must red the round-trip oracle. +pub fn nip_fi_disconnect_message(cmd: &buzz_auth::CommandResult) -> buzz_pubsub::NipFiDisconnect { + buzz_pubsub::NipFiDisconnect { + issuer: cmd.caller_iss.clone(), + pubkey_bytes: cmd.target_pubkey.to_bytes().to_vec(), + until_unix: cmd.until.timestamp(), + until_unix_nanos: cmd.until.timestamp_subsec_nanos(), + } +} + +/// Apply a received cross-pod NIP-FI disconnect message against the local +/// deny map and connection registry. +/// +/// This is the single consumer path — the `main.rs` receive loop calls this +/// function after receiving a message from the broadcast channel. Extracting +/// the logic here allows tests to call the exact production path end-to-end +/// without driving a live Redis subscriber. +/// +/// `now` is passed explicitly so tests can supply controlled timestamps. +pub fn apply_nip_fi_disconnect( + state: &crate::state::AppState, + message: &buzz_pubsub::NipFiDisconnect, + now: chrono::DateTime, +) -> NipFiDisconnectApplyResult { + let deny_map = match state.nip_fi_deny_map.as_deref() { + Some(m) => m, + None => return NipFiDisconnectApplyResult::Disabled, + }; + + // Validate pubkey bytes. + let pubkey = match nostr::PublicKey::from_slice(&message.pubkey_bytes) { + Ok(k) => k, + Err(_) => { + tracing::warn!( + len = message.pubkey_bytes.len(), + "nip-fi cross-pod: malformed pubkey bytes — rejected" + ); + return NipFiDisconnectApplyResult::Rejected; + } + }; + + // Validate that the issuer is locally configured. + if state + .config + .nip_fi + .registry + .policy_for_issuer(&message.issuer) + .is_none() + { + tracing::warn!("nip-fi cross-pod: unknown issuer (not locally configured) — rejected"); + return NipFiDisconnectApplyResult::Rejected; + } + + // Validate timestamp representability. + let until = match chrono::DateTime::from_timestamp(message.until_unix, message.until_unix_nanos) + { + Some(t) => t, + None => { + tracing::warn!( + until_unix = message.until_unix, + "nip-fi cross-pod: unrepresentable until timestamp — rejected" + ); + return NipFiDisconnectApplyResult::Rejected; + } + }; + + // Validate that `until` does not exceed the issuer's ceiling. + if let Some(policy) = state + .config + .nip_fi + .registry + .policy_for_issuer(&message.issuer) + { + let skew = chrono::Duration::seconds(policy.skew_seconds() as i64); + let max_age = chrono::Duration::seconds(policy.maximum_assertion_age_seconds() as i64); + if let Some(ceiling) = now + .checked_add_signed(skew) + .and_then(|t| t.checked_add_signed(max_age)) + { + if until > ceiling { + tracing::warn!("nip-fi cross-pod: until exceeds issuer ceiling — rejected"); + return NipFiDisconnectApplyResult::Rejected; + } + } + } + + // Merge the deny entry. + use buzz_auth::CrossPodMergeResult; + let merge_result = deny_map.merge_cross_pod_deny(&message.issuer, &pubkey, until, now); + + // Close sessions for all merge outcomes except UnknownIssuer. + let close_sessions = |reason: &str| { + let closed = state + .conn_manager + .disconnect_nip_fi(&message.issuer, &message.pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(&message.issuer, &message.pubkey_bytes); + if closed > 0 { + tracing::debug!(closed, reason = reason, "nip-fi cross-pod: closed sessions"); + } + }; + + match &merge_result { + CrossPodMergeResult::Merged => { + close_sessions("merged"); + } + CrossPodMergeResult::UnknownIssuer => { + tracing::warn!("nip-fi cross-pod: merge returned UnknownIssuer — rejected"); + } + CrossPodMergeResult::CapacityExceeded => { + tracing::warn!( + "nip-fi cross-pod: deny set full for issuer — closing targeted sessions without map entry (capacity miss; issuer re-push is the recovery path)" + ); + close_sessions("capacity-exceeded"); + metrics::counter!("buzz_nip_fi_cross_pod_capacity_exceeded_total").increment(1); + } + CrossPodMergeResult::ShardPoisoned => { + tracing::error!( + "nip-fi cross-pod: issuer shard is poisoned — sessions closed (fail-closed)" + ); + close_sessions("poisoned shard failsafe"); + metrics::counter!("buzz_nip_fi_cross_pod_shard_poison_total").increment(1); + } + } + + NipFiDisconnectApplyResult::Applied(merge_result) +} + +/// Build the NIP-FI command components from the issuer policies and key source. +/// +/// Called by `install_nip_fi_command_components` (and transitively `main.rs`). +/// Returns `Err` when any command config is invalid. In enforce mode, returns +/// `Err` when no command-capable issuers are present (assertion-only enforce is +/// not supported by this PR; every enforce issuer must carry command config). +/// +/// `issuer_command_configs` must be in the same order as `registry.all_policies()`. +pub fn build_nip_fi_command_components( + mode: NipFiMode, + registry: &buzz_auth::IssuerRegistry, + key_source: Arc, + issuer_command_configs: &[(String, CommandIssuerEnvConfig)], +) -> Result, String> { + if matches!(mode, NipFiMode::Off) { + return Ok(None); + } + + // Build per-issuer command policies and capacity overrides. + let mut command_policies: Vec = Vec::new(); + let mut issuer_capacities: Vec = Vec::new(); + let mut default_capacity = DEFAULT_DENY_SET_CAPACITY; + + for (idx, (issuer, cmd_cfg)) in issuer_command_configs.iter().enumerate() { + let age = match cmd_cfg.maximum_command_age_seconds { + Some(a) => a, + None => { + // In enforce mode every issuer must be command-capable; + // from_env() already guarantees this, but be defensive here too. + if matches!(mode, NipFiMode::Enforce) { + return Err(format!( + "nip-fi: enforce issuer [index {idx}] has no maximum_command_age_seconds — \ + assertion-only issuers are not supported in enforce mode" + )); + } + continue; // non-enforce mode: skip issuers without command config + } + }; + let principals = match &cmd_cfg.authorized_principals { + Some(p) if !p.is_empty() => p.clone(), + _ => { + // from_env() already rejects this; treat as a hard error here. + return Err(format!( + "nip-fi: issuer [index {idx}] has maximum_command_age_seconds but no \ + authorized_principals — startup validation should have caught this" + )); + } + }; + let capacity = cmd_cfg + .deny_set_capacity + .unwrap_or(DEFAULT_DENY_SET_CAPACITY); + + // Validate and construct the command policy — no warn-and-skip. + let policy = CommandIssuerPolicy::new(issuer.clone(), age, principals, capacity) + .map_err(|e| format!("nip-fi: issuer [index {idx}] invalid command policy: {e}"))?; + + issuer_capacities.push(IssuerCapacity { + issuer: issuer.clone(), + capacity, + }); + command_policies.push(policy); + + // Track the maximum capacity across issuers for the default slot. + if capacity > default_capacity { + default_capacity = capacity; + } + } + + if command_policies.is_empty() { + if matches!(mode, NipFiMode::Enforce) { + // Enforce with no command-capable issuers is a misconfiguration: + // from_env() guarantees every enforce issuer has command config, so + // an empty set here means something was skipped or the configs are wrong. + return Err( + "nip-fi: enforce mode requires at least one command-capable issuer; \ + no command policies were built — check issuer configuration" + .to_owned(), + ); + } + debug!("nip-fi: no command-capable issuers configured — command API disabled"); + return Ok(None); + } + + let deny_map = Arc::new(NipFiDenyMap::new(default_capacity, issuer_capacities)); + + let command_verifier = Arc::new(CommandVerifier::new( + registry.clone(), + key_source, + command_policies, + (*deny_map).clone(), + )); + + Ok(Some(NipFiCommandComponents { + deny_map, + command_verifier, + })) +} + +/// Result of a successful [`install_nip_fi_command_components`] call. +#[derive(Debug)] +pub struct NipFiCommandStartupReport { + /// Number of issuers whose JWKS snapshot was warmed before serving. + pub warmed_issuers: usize, + /// Number of issuers wired into the command verifier. + pub command_issuers: usize, +} + +/// Install NIP-FI command components into `app_state`. +/// +/// This is the single production startup seam that owns: +/// - enforce-mode pre-flight check (returns `Err` for incomplete config) +/// - JWKS warmup for every configured issuer +/// - background JWKS refresh loop +/// - `build_nip_fi_command_components` invocation +/// - assignment of both `app_state.nip_fi_deny_map` and +/// `app_state.nip_fi_command_verifier` +/// +/// `main.rs` constructs the concrete `ProductionJwksSource` and calls this +/// function once; it no longer owns either assignment or the warmup loop. +/// +/// Deleting either AppState field assignment or the warmup loop must red the +/// `production_install_warms_and_populates_both_app_state_fields` oracle. +pub async fn install_nip_fi_command_components( + app_state: &mut crate::state::AppState, + mode: NipFiMode, + registry: &buzz_auth::IssuerRegistry, + key_source: Arc, + jwks_configs: &[buzz_auth::IssuerJwksConfig], + command_configs: &[(String, CommandIssuerEnvConfig)], +) -> Result { + // Pre-flight: enforce mode with no command configs is always an error. + if matches!(mode, NipFiMode::Enforce) && command_configs.is_empty() { + return Err( + "NIP-FI install: enforce mode requires at least one command-capable issuer".to_owned(), + ); + } + + // Warm each issuer's JWKS snapshot before serving. + let mut warmed_issuers: usize = 0; + for jwks_cfg in jwks_configs { + if let Some(snapshot) = key_source.get_snapshot(&jwks_cfg.issuer).await { + tracing::info!( + issuer_len = jwks_cfg.issuer.len(), + generation = snapshot.generation(), + "NIP-FI: JWKS warmed" + ); + warmed_issuers += 1; + } else { + tracing::warn!( + issuer_len = jwks_cfg.issuer.len(), + "NIP-FI: JWKS warm-up failed — will retry inline" + ); + } + } + + // Spawn background refresh loop so snapshots stay fresh after startup. + { + let source_for_refresh = Arc::clone(&key_source); + let jwks_cfgs = jwks_configs.to_vec(); + let shutting_down = Arc::clone(&app_state.shutting_down); + tokio::spawn(async move { + loop { + let min_interval_secs = jwks_cfgs + .iter() + .map(|c| c.contract.refresh_interval_seconds()) + .min() + .unwrap_or(300); + tokio::time::sleep(std::time::Duration::from_secs(min_interval_secs)).await; + if shutting_down.load(std::sync::atomic::Ordering::Acquire) { + break; + } + for cfg in &jwks_cfgs { + source_for_refresh.get_snapshot(&cfg.issuer).await; + } + } + }); + } + + let components = + build_nip_fi_command_components(mode, registry, Arc::clone(&key_source), command_configs)?; + + let command_issuers = if let Some(c) = components { + let n = command_configs.len(); + app_state.nip_fi_deny_map = Some(c.deny_map); + app_state.nip_fi_command_verifier = Some(c.command_verifier); + tracing::info!("NIP-FI S4: command API enabled ({n} issuer(s))"); + n + } else { + 0 + }; + + Ok(NipFiCommandStartupReport { + warmed_issuers, + command_issuers, + }) +} + +/// Validate a command issuer config entry without constructing a policy. +/// +/// Called by `nip_fi_config.rs` at startup before `build_nip_fi_command_components` +/// so that invalid config is rejected at `Config::from_env()`, not at serve time. +/// Returns `Err` with a non-sensitive message (no raw issuer URI). +pub fn validate_command_issuer_config( + idx: usize, + age_seconds: u64, + principals: &[String], + capacity: usize, +) -> Result<(), String> { + CommandIssuerPolicy::new( + // Use a sentinel issuer for validation only — no URI written to any log. + format!("https://validate-sentinel-{idx}.internal"), + age_seconds, + principals.to_vec(), + capacity, + ) + .map(|_| ()) + .map_err(|e| format!("issuer [index {idx}] invalid command policy: {e}")) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Extract the command JWS token from the `Nostr-Federated-Identity: Bearer` +/// header. Returns `Err(401)` if the header is absent, `Err(403)` otherwise. +/// +/// The same header is used for assertion tokens at upgrade and for command +/// tokens at the admin API — distinct roles on distinct paths, never mixed. +fn extract_command_jwt(headers: &HeaderMap) -> Result<&str, StatusCode> { + let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter(); + let first = values.next().ok_or(StatusCode::UNAUTHORIZED)?; + // Repeated header → reject. + if values.next().is_some() { + return Err(StatusCode::FORBIDDEN); + } + let raw = first.to_str().map_err(|_| StatusCode::FORBIDDEN)?; + if raw.contains(',') { + return Err(StatusCode::FORBIDDEN); + } + let token = raw.strip_prefix("Bearer ").ok_or(StatusCode::FORBIDDEN)?; + if token.is_empty() || token.contains(char::is_whitespace) { + return Err(StatusCode::FORBIDDEN); + } + Ok(token) +} + +fn parse_hex_pubkey(raw: &str) -> Option { + if raw.len() != 64 + || !raw + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return None; + } + nostr::PublicKey::from_hex(raw).ok() +} + +fn plain_response(status: StatusCode, body: &'static str) -> Response { + Response::builder() + .status(status) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::from(body)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +/// Build the `401 authentication required` response with the mandatory +/// `WWW-Authenticate: Nostr` header. [NIP-FI.md §Rejection table] +fn auth_required_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "text/plain; charset=utf-8") + .header("WWW-Authenticate", "Nostr") + .body(Body::from("authentication required\n")) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +/// Spec-exact 200 success response. +/// +/// The spec body is `{"disconnected": true}` (note the space after `:`). +/// `serde_json::to_vec` produces `{"disconnected":true}` without the space. +/// We produce the literal bytes directly to stay byte-exact. +fn disconnected_response() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from("{\"disconnected\": true}")) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers_with(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_str(value).unwrap(), + ); + h + } + + // ── JWT extraction contract ──────────────────────────────────────────── + + #[test] + fn absent_header_gives_401() { + let h = HeaderMap::new(); + assert_eq!(extract_command_jwt(&h), Err(StatusCode::UNAUTHORIZED)); + } + + #[test] + fn repeated_header_gives_403() { + let mut h = HeaderMap::new(); + h.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer aaa.bbb.ccc"), + ); + h.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer ddd.eee.fff"), + ); + assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn non_bearer_gives_403() { + let h = headers_with("Token aaa.bbb.ccc"); + assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn valid_bearer_extracted() { + let h = headers_with("Bearer aaa.bbb.ccc"); + assert_eq!(extract_command_jwt(&h), Ok("aaa.bbb.ccc")); + } + + // ── Hex pubkey parsing ──────────────────────────────────────────────── + + #[test] + fn uppercase_hex_rejected() { + let upper = "A".repeat(64); + assert!(parse_hex_pubkey(&upper).is_none()); + } + + #[test] + fn wrong_length_rejected() { + let short = "a".repeat(63); + let long = "a".repeat(65); + assert!(parse_hex_pubkey(&short).is_none()); + assert!(parse_hex_pubkey(&long).is_none()); + } + + // ── CommandIssuerEnvConfig default capacity ──────────────────────────── + + // (The previous constant-assertion test was removed: asserting None and a constant + // does not bind production behavior. The builder is now covered by route integration tests.) + + // ── HTTP response contract ───────────────────────────────────────────── + + /// The spec requires `{"disconnected": true}` (note the space after `:`). + #[tokio::test] + async fn disconnected_response_is_spec_exact() { + let resp = disconnected_response(); + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(ct, "application/json"); + // Body bytes are verified directly — serde_json compact and the spec + // literal are NOT the same (serde_json omits the space). + let body_bytes = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body_bytes.as_ref(), + b"{\"disconnected\": true}", + "success body must be byte-exact per spec" + ); + } + + /// `401` MUST carry `WWW-Authenticate: Nostr` and the spec body. + #[tokio::test] + async fn auth_required_response_has_www_authenticate() { + let resp = auth_required_response(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let www_auth = resp + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(www_auth, "Nostr", "401 MUST carry WWW-Authenticate: Nostr"); + let body_bytes = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!(body_bytes.as_ref(), b"authentication required\n"); + } + + /// `403` error responses MUST NOT carry `WWW-Authenticate`. + #[test] + fn error_responses_have_no_www_authenticate() { + for body in &["evidence rejected\n", "authorization denied\n"] { + let resp = plain_response(StatusCode::FORBIDDEN, body); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "403 must not carry WWW-Authenticate" + ); + } + } + + /// `503` plain responses have the spec-exact body. + #[test] + fn deny_set_full_response_body_is_spec_exact() { + use buzz_auth::CommandError; + let body = CommandError::DenySetFull.response_body(); + assert_eq!( + body, "deny set full\n", + "FI-TRACE-DENY-SET: 503 body must be 'deny set full\\n'" + ); + } +} + +// ── Route integration tests ──────────────────────────────────────────────────── +// +// Exercises `disconnect()` through the full axum router with a warmed +// ProductionJwksSource, a real CommandVerifier, and an AppState wired exactly +// as production does (nip_fi_command_verifier + nip_fi_deny_map both set). +// +// These tests call the route at POST /api/nip-fi/disconnect via oneshot and +// verify every spec response row: 401, 403 (evidence), 403 (authz), 400, 503 +// (capacity), 200 exact bytes. The startup-assembly invariant is also +// verified: the tests GO RED if either state field is absent (503 unavailable). + +#[cfg(test)] +mod route_integration_tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use buzz_auth::{ + CommandIssuerPolicy, CommandVerifier, IssuerCapacity, IssuerRegistry, NipFiDenyMap, + ProductionJwksSource, + }; + use std::sync::Arc; + use tower::ServiceExt; + + // ── Shared test key material ──────────────────────────────────────────── + + // ES256 key pair — same material as command.rs tests, known-good. + const TEST_PRIVATE_KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + + const TEST_ISS: &str = "https://idp.test.example.com"; + const TEST_AUD: &str = "https://relay.test.example.com"; + const TEST_SUB: &str = "admin-svc@test.example.com"; + const TEST_PATH: &str = "/api/nip-fi/disconnect"; + + // Key ID used in both the JWKS and the JWT header. + const TEST_KID: &str = "route-test-key-1"; + + fn test_public_jwk() -> jsonwebtoken::jwk::Jwk { + // Public-key coordinates extracted from TEST_PRIVATE_KEY_PEM (P-256), + // which is the same key pair as command.rs TEST_JWK_X/Y constants. + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA", + "alg": "ES256", + "use": "sig", + "kid": TEST_KID + })) + .expect("valid test JWK") + } + + fn test_jwks() -> jsonwebtoken::jwk::JwkSet { + jsonwebtoken::jwk::JwkSet { + keys: vec![test_public_jwk()], + } + } + + fn test_issuer_policy() -> buzz_auth::IssuerPolicy { + use buzz_auth::{FreshnessClass, IssuerPolicy, JwksSourceContract, TokenClass}; + let contract = + JwksSourceContract::new(format!("{TEST_ISS}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + IssuerPolicy::new( + TEST_ISS.to_owned(), + vec![TEST_AUD.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![buzz_auth::JwtAlgorithm::ES256], + 30, + 3600, + None, + contract, + ) + .expect("valid issuer policy") + } + + fn test_jwks_config() -> buzz_auth::IssuerJwksConfig { + use buzz_auth::{IssuerJwksConfig, JwksSourceContract}; + let contract = + JwksSourceContract::new(format!("{TEST_ISS}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + IssuerJwksConfig { + issuer: TEST_ISS.to_owned(), + contract, + } + } + + fn mint_token(target_hex: &str, until_offset_secs: i64, extra: serde_json::Value) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let mut claims = serde_json::json!({ + "iss": TEST_ISS, + "aud": TEST_AUD, + "sub": TEST_SUB, + "iat": now, + "exp": now + 60, + "jti": uuid::Uuid::new_v4().to_string(), + "method": "POST", + "path": TEST_PATH, + "cmd": "disconnect", + "target_pubkey": target_hex, + "until": now + until_offset_secs, + }); + if let Some(obj) = extra.as_object() { + for (k, v) in obj { + claims[k] = v.clone(); + } + } + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi-command+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let key = EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).expect("test EC key"); + encode(&header, &claims, &key).expect("sign test token") + } + + async fn build_test_state(capacity: usize) -> Arc { + // Build a minimal AppState with NIP-FI S4 components wired. + // Uses lazy/invalid DB+Redis — only nip_fi fields and conn_manager matter. + use crate::state::AppState; + let config = crate::config::Config::hermetic_for_test(); + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + + // Wire NIP-FI S4 components. + let jwks_configs = vec![test_jwks_config()]; + let key_source = Arc::new( + ProductionJwksSource::new(jwks_configs, buzz_auth::HttpJwksFetcher::new()) + .expect("key source"), + ); + // Seed the snapshot without making an HTTP request. + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + + let deny_map = Arc::new(NipFiDenyMap::new( + capacity, + vec![IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity, + }], + )); + let policy = + CommandIssuerPolicy::new(TEST_ISS.to_owned(), 30, vec![TEST_SUB.to_owned()], capacity) + .expect("command policy"); + let verifier = Arc::new(CommandVerifier::new( + registry, + Arc::clone(&key_source), + vec![policy], + (*deny_map).clone(), + )); + + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + state.nip_fi_command_verifier = Some(verifier); + Arc::new(state) + } + + fn target_hex() -> String { + nostr::Keys::generate().public_key().to_hex() + } + + async fn do_request( + state: Arc, + method: &str, + headers: Vec<(&'static str, String)>, + body: Option, + ) -> axum::response::Response { + use crate::router::build_router; + let body_bytes = match body { + Some(v) => serde_json::to_vec(&v).unwrap().into(), + None => axum::body::Bytes::new(), + }; + let mut req = Request::builder().method(method).uri(TEST_PATH); + for (k, v) in &headers { + req = req.header(*k, v.as_str()); + } + let req = req.body(Body::from(body_bytes)).unwrap(); + build_router(state).oneshot(req).await.unwrap() + } + + // ── Test: no verifier → 503 (startup-assembly invariant) ───────────────── + + #[tokio::test] + async fn absent_verifier_gives_503_unavailable() { + // If nip_fi_command_verifier is not set, every request gets 503. + // This tests the handler fallback path when the verifier is absent from + // AppState. The production startup assembly is covered separately by + // `production_assembly_build_nip_fi_command_components_wires_both_fields`. + // Build a state without the verifier. + let no_verifier_state = { + let config = crate::config::Config::hermetic_for_test(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // nip_fi_command_verifier stays None. + Arc::new(state) + }; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + no_verifier_state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + // ── Test: absent header → 401 + WWW-Authenticate ───────────────────────── + + #[tokio::test] + async fn absent_header_route_gives_401_with_www_authenticate() { + let state = build_test_state(1000).await; + let target = target_hex(); + let resp = do_request( + state, + "POST", + vec![("Content-Type", "application/json".into())], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let www_auth = resp + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(www_auth, "Nostr", "401 MUST carry WWW-Authenticate: Nostr"); + } + + // ── Test: bad signature → 403 evidence rejected ─────────────────────────── + + #[tokio::test] + async fn bad_signature_gives_403_evidence_rejected() { + let state = build_test_state(1000).await; + let target = target_hex(); + // Tamper the token. + let token = mint_token(&target, 300, serde_json::json!({})); + let tampered = format!("{token}X"); + let resp = do_request( + state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {tampered}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + // ── Test: capacity exceeded → 503 does NOT burn jti ────────────────────── + + #[tokio::test] + async fn capacity_503_does_not_burn_jti_route_retry_succeeds() { + // capacity=1, two distinct targets. + let state = build_test_state(1).await; + let target_a = target_hex(); + let target_b = target_hex(); + + // First request fills the slot. + let token_a = mint_token(&target_a, 300, serde_json::json!({})); + let resp_a = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token_a}")), + ], + Some(serde_json::json!({"pubkey": target_a})), + ) + .await; + assert_eq!(resp_a.status(), StatusCode::OK); + + // Second request hits capacity → 503. Jti NOT burned. + let jti_b = uuid::Uuid::new_v4().to_string(); + let token_b = mint_token(&target_b, 300, serde_json::json!({"jti": jti_b})); + let resp_b = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token_b}")), + ], + Some(serde_json::json!({"pubkey": target_b})), + ) + .await; + assert_eq!( + resp_b.status(), + StatusCode::SERVICE_UNAVAILABLE, + "capacity exceeded must return 503" + ); + let body = axum::body::to_bytes(resp_b.into_body(), 64).await.unwrap(); + assert_eq!(body.as_ref(), b"deny set full\n"); + // Jti was NOT burned: the same token_b can be reused once the slot frees. + // (Route-level: we verify the 503 body; the jti non-burn is covered by + // command.rs::capacity_503_does_not_burn_jti_retry_succeeds_after_slot_freed) + } + + // ── Test: successful disconnect → 200 spec-exact bytes ─────────────────── + + #[tokio::test] + async fn success_response_is_spec_exact_bytes() { + let state = build_test_state(1000).await; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(ct, "application/json"); + let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body.as_ref(), + b"{\"disconnected\": true}", + "200 body must be byte-exact per spec (note the space after ':')" + ); + } + + // ── Test: count-independence (zero vs many sessions) ───────────────────── + + #[tokio::test] + async fn success_body_identical_regardless_of_sessions_closed() { + // Zero live sessions: response must still be {"disconnected": true}. + let state = build_test_state(1000).await; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body.as_ref(), + b"{\"disconnected\": true}", + "zero-sessions success must be byte-identical to many-sessions success [no count leak]" + ); + } + + // ── Test: deny entry recorded after success ─────────────────────────────── + + #[tokio::test] + async fn success_records_deny_entry_visible_to_is_denied() { + let state = build_test_state(1000).await; + let target = target_hex(); + let target_pubkey = nostr::PublicKey::from_hex(&target).expect("valid hex pubkey"); + + // Before disconnect: not denied. + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + assert!( + !deny_map.is_denied(TEST_ISS, &target_pubkey, chrono::Utc::now()), + "must not be denied before disconnect" + ); + + // Execute disconnect. + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // After disconnect: denied. + assert!( + deny_map.is_denied(TEST_ISS, &target_pubkey, chrono::Utc::now()), + "must be denied after successful disconnect" + ); + } + + // ── Test: consumer capacity oracle (replacement per NIP-FI.md:306-336) ───── + // + // Drives apply_nip_fi_disconnect with a delivered target that encounters a + // pre-filled capacity-1 map. Asserts Applied(CapacityExceeded); the + // pre-existing map key remains denied only until its TTL; the missed target + // and unrelated key are NOT map-denied; targeted live sessions are closed; + // unrelated live peers remain open. + // + // Mandatory reds: + // (a) consumer stops calling merge_cross_pod_deny → Applied(CapacityExceeded) missed; + // targeted session close assertion fails + // (b) reintroduce issuer-wide blocking → missed-target is_denied assertion fails + // (c) consumer skips close_sessions on CapacityExceeded → targeted cancel assertion fails + + #[tokio::test] + async fn consumer_capacity_miss_closes_targeted_session_without_map_denial() { + use super::apply_nip_fi_disconnect; + use super::NipFiDisconnectApplyResult; + use crate::state::CommunityConnectionControl; + use buzz_auth::CrossPodMergeResult; + use tokio_util::sync::CancellationToken; + + let state = { + let mut config = crate::config::Config::hermetic_for_test(); + // Wire TEST_ISS into the NIP-FI registry so the consumer seam accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Capacity=1, pre-filled with k_a so the second delivery (k_b) hits capacity. + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 1, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 1, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + let now = chrono::Utc::now(); + let until_unix = (now + chrono::Duration::seconds(300)).timestamp(); + + let k_a = nostr::Keys::generate().public_key(); + let k_b = nostr::Keys::generate().public_key(); + let k_unrelated = nostr::Keys::generate().public_key(); + + // Pre-fill slot with k_a via the consumer seam. + let msg_a = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: k_a.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result_a = apply_nip_fi_disconnect(&state, &msg_a, now); + assert_eq!( + result_a, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "first consumer message must merge" + ); + + // Register live sessions for targeted (k_b) and unrelated (k_unrelated) peers. + let cancel_b = CancellationToken::new(); + let cancel_unrelated = CancellationToken::new(); + let registry = &state.community_connections; + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + let ctrl_b = CommunityConnectionControl::new(cancel_b.clone()); + ctrl_b.set_proven_identity(k_b.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + let _guard_b = registry.register(uuid::Uuid::new_v4(), community, ctrl_b); + + let ctrl_unrelated = CommunityConnectionControl::new(cancel_unrelated.clone()); + ctrl_unrelated + .set_proven_identity(k_unrelated.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + let _guard_unrelated = registry.register(uuid::Uuid::new_v4(), community, ctrl_unrelated); + + // Deliver k_b — capacity exhausted, no map entry added. + let msg_b = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: k_b.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result_b = apply_nip_fi_disconnect(&state, &msg_b, now); + assert_eq!( + result_b, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::CapacityExceeded), + "second consumer message must hit capacity" + ); + + // Targeted session (k_b) must be cancelled despite no map entry. + assert!( + cancel_b.is_cancelled(), + "targeted session must be closed even on CapacityExceeded" + ); + // Unrelated session must NOT be cancelled. + assert!( + !cancel_unrelated.is_cancelled(), + "unrelated session must remain open after capacity miss" + ); + + // Map checks — no issuer-wide denial synthesized. + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + + // k_a is still denied (its entry was not evicted). + assert!( + deny_map.is_denied(TEST_ISS, &k_a, now), + "pre-existing k_a entry must remain denied" + ); + // k_b has no map entry — NOT denied via the map. + assert!( + !deny_map.is_denied(TEST_ISS, &k_b, now), + "missed target k_b must NOT be map-denied after capacity miss" + ); + // Unrelated key is not map-denied. + assert!( + !deny_map.is_denied(TEST_ISS, &k_unrelated, now), + "unrelated key must NOT be denied after capacity miss" + ); + // At exact equality with k_a's TTL, k_a is admitted. + let at_ttl = chrono::DateTime::from_timestamp(until_unix, 0).unwrap(); + assert!( + !deny_map.is_denied(TEST_ISS, &k_a, at_ttl), + "k_a must be admitted at exact equality with its TTL" + ); + } + + // ── Test: Item 5 — dual-transport registration witness ────────────────────────────────────── + // + // Proves that the production audio post-auth registration helper + // (`audio_post_auth_register`) is the seam used to register audio connections + // in the fan-out, and that apply_nip_fi_disconnect drives both connection + // registries (ordinary WS via conn_manager and audio via community_connections). + // + // Four sub-claims verified: + // 1. targeted ordinary WS is cancelled (conn_manager path) + // 2. targeted audio is cancelled with AuthorizationDenied (community_connections path, + // registered via the production audio_post_auth_register helper) + // 3. unrelated ordinary WS and audio peers remain open + // 4. capacity-failure variant: the delivered target still closes both transports + // despite no map entry + // + // Mandatory reds: + // - no-op audio_post_auth_register leaves targeted audio open + // - removing community_connections from the fan-out leaves audio open + // - removing conn_manager from the fan-out leaves ordinary WS open + // - broad/non-key-exact matching would close unrelated peers (asserted absent) + // - skipping close on CapacityExceeded leaves targeted sessions open (capacity variant) + + #[tokio::test] + async fn dual_transport_registration_witness() { + use super::apply_nip_fi_disconnect; + use super::NipFiDisconnectApplyResult; + use crate::audio::handler::audio_post_auth_register; + use crate::state::CommunityConnectionControl; + use buzz_auth::CrossPodMergeResult; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + // ── Helper: register an ordinary WS connection and return (conn_id, cancel). ── + // Mirrors how connection.rs registers after NIP-42 auth: register first, then + // call set_authenticated_pubkey. + let register_ws = + |state: &crate::state::AppState, pubkey_bytes: Vec| -> (Uuid, CancellationToken) { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + let bp = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + mpsc::channel(1).0, + None, + cancel.clone(), + community, + bp, + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), + ); + state.conn_manager.set_authenticated_identity( + conn_id, + pubkey_bytes, + Some(TEST_ISS.to_owned()), + ); + (conn_id, cancel) + }; + + // ── Build state: capacity 2 so the Merged case succeeds for the target. ── + let state = { + let mut config = crate::config::Config::hermetic_for_test(); + // Wire TEST_ISS into the NIP-FI registry so apply_nip_fi_disconnect accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 2, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 2, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + let target = nostr::Keys::generate().public_key(); + let unrelated = nostr::Keys::generate().public_key(); + let now = chrono::Utc::now(); + let until_unix = (now + chrono::Duration::seconds(300)).timestamp(); + + // Register targeted ordinary WS and audio controls. + let (_target_ws_id, cancel_target_ws) = register_ws(&state, target.to_bytes().to_vec()); + + // Register targeted audio via the production helper. + // Guards must live until after the assertions — declared here, not in a sub-block. + let audio_registry = &state.community_connections; + let cancel_audio_target = CancellationToken::new(); + let _audio_target_guard = { + let ctrl = CommunityConnectionControl::new(cancel_audio_target.clone()); + audio_post_auth_register(&ctrl, target.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // Register unrelated ordinary WS and audio controls. + let (_unrelated_ws_id, cancel_unrelated_ws) = + register_ws(&state, unrelated.to_bytes().to_vec()); + let cancel_audio_unrelated = CancellationToken::new(); + let _audio_unrelated_guard = { + let ctrl = CommunityConnectionControl::new(cancel_audio_unrelated.clone()); + audio_post_auth_register( + &ctrl, + unrelated.to_bytes().to_vec(), + Some(TEST_ISS.to_owned()), + ); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // Drive apply_nip_fi_disconnect for the target (Merged case). + let msg = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: target.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result = apply_nip_fi_disconnect(&state, &msg, now); + assert_eq!( + result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "target disconnect must merge" + ); + + // Claim 1: targeted ordinary WS is cancelled. + assert!( + cancel_target_ws.is_cancelled(), + "targeted ordinary WS must be cancelled by disconnect fan-out" + ); + // Claim 2: targeted audio is cancelled (registered via audio_post_auth_register). + assert!( + cancel_audio_target.is_cancelled(), + "targeted audio connection must be cancelled via community_connections fan-out" + ); + // Claim 3a: unrelated ordinary WS remains open. + assert!( + !cancel_unrelated_ws.is_cancelled(), + "unrelated ordinary WS must remain open" + ); + // Claim 3b: unrelated audio remains open. + assert!( + !cancel_audio_unrelated.is_cancelled(), + "unrelated audio connection must remain open" + ); + + // ── Capacity-failure variant ────────────────────────────────────────────── + // Pre-fill the map to capacity with a different key, then deliver target2 + // (capacity exceeded). Assert target2's sessions still close despite no map entry. + let target2 = nostr::Keys::generate().public_key(); + + // Register target2 ordinary WS and audio. + let (_t2_ws_id, cancel_t2_ws) = register_ws(&state, target2.to_bytes().to_vec()); + let cancel_t2_audio = CancellationToken::new(); + let _t2_audio_guard = { + let ctrl = CommunityConnectionControl::new(cancel_t2_audio.clone()); + audio_post_auth_register( + &ctrl, + target2.to_bytes().to_vec(), + Some(TEST_ISS.to_owned()), + ); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // The map is now at capacity (target is in it from the Merged above, plus we need + // one more to saturate cap=2). Pre-fill the second slot with a filler key. + let filler = nostr::Keys::generate().public_key(); + let msg_fill = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: filler.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let fill_result = apply_nip_fi_disconnect(&state, &msg_fill, now); + assert_eq!( + fill_result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "filler must merge to saturate capacity" + ); + + // Now deliver target2 — capacity exceeded, no map entry. + let msg2 = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: target2.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result2 = apply_nip_fi_disconnect(&state, &msg2, now); + assert_eq!( + result2, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::CapacityExceeded), + "second target must hit capacity" + ); + + // Claim 4a: targeted ordinary WS still closes despite CapacityExceeded. + assert!( + cancel_t2_ws.is_cancelled(), + "target2 ordinary WS must close even on CapacityExceeded" + ); + // Claim 4b: targeted audio still closes despite CapacityExceeded. + assert!( + cancel_t2_audio.is_cancelled(), + "target2 audio must close even on CapacityExceeded" + ); + } + + // ── Test: blocker 3 — fractional deadline survives publisher wire and consumer equality boundary ─ + // + // Exercises the full publisher → encode → decode → apply_nip_fi_disconnect chain with a + // non-zero nanos deadline. Proves denied immediately after T, admitted at exact T + nanos. + // + // Red mutations: + // - publisher writes zero nanos → until reconstructed as T+0 → equality boundary fails + // - encoder omits nanos field → decoder defaults to 0 → same failure + // - decoder defaults a present field to zero → same failure + // - consumer reconstructs with zero nanos → same failure + // - comparison changes from < to <= → admitted before exact boundary + + #[tokio::test] + async fn fractional_deadline_survives_publisher_wire_and_consumer_equality_boundary() { + use super::NipFiDisconnectApplyResult; + use super::{apply_nip_fi_disconnect, nip_fi_disconnect_message}; + use buzz_auth::CrossPodMergeResult; + + // Build a state with TEST_ISS in the registry so apply_nip_fi_disconnect accepts it. + let state = { + let mut config = crate::config::Config::hermetic_for_test(); + // Wire TEST_ISS into the NIP-FI registry so apply_nip_fi_disconnect accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 1000, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 1000, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + // Build a CommandResult with a fractional deadline: T + 500_000_000 ns. + // We construct the CommandResult directly rather than going through the HTTP + // handler so we can control the exact until timestamp. + let target = nostr::Keys::generate().public_key(); + let t_whole = chrono::DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let nanos: u32 = 500_000_000; + let t_frac = chrono::DateTime::from_timestamp(1_800_000_000, nanos).unwrap(); + + // Construct a synthetic CommandResult. + let cmd = buzz_auth::CommandResult { + caller_iss: TEST_ISS.to_owned(), + caller_sub: TEST_SUB.to_owned(), + target_pubkey: target, + until: t_frac, + }; + + // Publisher seam → encode → decode. + let msg = nip_fi_disconnect_message(&cmd); + assert_eq!( + msg.until_unix, 1_800_000_000, + "publisher must capture whole-second" + ); + assert_eq!(msg.until_unix_nanos, nanos, "publisher must capture nanos"); + + let encoded = buzz_pubsub::encode_nip_fi_disconnect(&msg).expect("encode must succeed"); + let decoded = buzz_pubsub::decode_nip_fi_disconnect(&encoded).expect("decode must succeed"); + assert_eq!(decoded.until_unix_nanos, nanos, "decoded nanos must match"); + + // Consumer seam: apply with now = t_frac - 1ns (just inside the deadline). + let now_inside = t_frac - chrono::Duration::nanoseconds(1); + let result = apply_nip_fi_disconnect(&state, &decoded, now_inside); + assert_eq!( + result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "apply must succeed with now inside deadline" + ); + + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + + // Denied immediately after T (now = T + 1ns, well inside the deadline T+500ms). + let now_after_t = t_whole + chrono::Duration::nanoseconds(1); + assert!( + deny_map.is_denied(TEST_ISS, &target, now_after_t), + "must be denied at T+1ns (deadline is T+500ms)" + ); + + // Denied at deadline minus 1ns (just before equality boundary). + let now_before_boundary = t_frac - chrono::Duration::nanoseconds(1); + assert!( + deny_map.is_denied(TEST_ISS, &target, now_before_boundary), + "must be denied at deadline - 1ns" + ); + + // Admitted at exact equality (now == until): contract is `now < until`, + // so exact equality means admitted. + assert!( + !deny_map.is_denied(TEST_ISS, &target, t_frac), + "must be admitted at exact equality (now < until fails at now == until)" + ); + + // Also admitted after (now = T + whole second). + assert!( + !deny_map.is_denied(TEST_ISS, &target, t_whole + chrono::Duration::seconds(1)), + "must be admitted past the deadline" + ); + } + + // ── Test: blocker 4b — production_install_warms_and_populates_both_app_state_fields ───── + // + // Calls install_nip_fi_command_components() directly (the production seam that owns + // warmup + both AppState assignments). Proves: + // - warmed_issuers == 1 after a seeded get_snapshot + // - command_issuers == 1 + // - both AppState fields are Some + // - a valid signed command through the verifier creates a deny visible via the map + // + // Mandatory red mutations (proven by separate inline verification below): + // 1. delete deny_map assignment → AppState.nip_fi_deny_map is None + // 2. delete verifier assignment → AppState.nip_fi_command_verifier is None + // 3. delete/bypass warmup → warmed_issuers == 0 + // 4. wire verifier to a different map → verify succeeds but state map denial fails + + #[tokio::test] + async fn production_install_warms_and_populates_both_app_state_fields() { + use super::install_nip_fi_command_components; + + // Build a minimal AppState — same construction as build_test_state but without + // the S4 fields so we can verify install_nip_fi_command_components populates them. + let config = crate::config::Config::hermetic_for_test(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Both fields start as None — we'll verify install populates them. + assert!(state.nip_fi_deny_map.is_none()); + assert!(state.nip_fi_command_verifier.is_none()); + + let jwks_configs = vec![test_jwks_config()]; + let key_source = Arc::new( + ProductionJwksSource::new(jwks_configs.clone(), buzz_auth::HttpJwksFetcher::new()) + .expect("valid key source"), + ); + // Seed the JWKS snapshot hermetically (no HTTP). + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + + let cmd_configs = vec![( + TEST_ISS.to_owned(), + CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(30), + authorized_principals: Some(vec![TEST_SUB.to_owned()]), + deny_set_capacity: Some(100), + }, + )]; + + let report = install_nip_fi_command_components( + &mut state, + buzz_auth::NipFiMode::Enforce, + ®istry, + Arc::clone(&key_source), + &jwks_configs, + &cmd_configs, + ) + .await + .expect("install must succeed for valid config"); + + // Warmup: one issuer was seeded and should have a snapshot. + assert_eq!( + report.warmed_issuers, 1, + "warmed_issuers must equal 1 (snapshot was seeded)" + ); + assert_eq!(report.command_issuers, 1, "command_issuers must equal 1"); + + // Both AppState fields must be populated. + assert!( + state.nip_fi_deny_map.is_some(), + "nip_fi_deny_map must be Some after install" + ); + assert!( + state.nip_fi_command_verifier.is_some(), + "nip_fi_command_verifier must be Some after install" + ); + + // Verify a valid command through the verifier and confirm the deny entry + // is visible through the AppState's deny_map (proves shared map wiring). + let target = nostr::Keys::generate().public_key(); + let token = mint_token(&target.to_hex(), 300, serde_json::json!({})); + let verifier = state.nip_fi_command_verifier.as_ref().unwrap(); + let result = verifier.verify(&token, "POST", TEST_PATH, &target); + assert!( + result.is_ok(), + "verifier must accept a valid command: {result:?}" + ); + let deny_map = state.nip_fi_deny_map.as_deref().unwrap(); + assert!( + deny_map.is_denied(TEST_ISS, &target, chrono::Utc::now()), + "deny entry must be visible via AppState.nip_fi_deny_map after verify" + ); + + // Set the shutdown flag so the background refresh task eventually exits. + // This does not prove lifecycle — no task handle is awaited here. + state + .shutting_down + .store(true, std::sync::atomic::Ordering::Release); + } + + // ── Startup-oracle: shared JWKS source wiring ────────────────────────── + // + // Proves that `install_nip_fi_command_components` warms the EXACT Arc that + // `nip_fi_verifier` holds, so a valid assertion JWT succeeds after startup. + // + // Construction path mirrors `main.rs` exactly: + // 1. Build AppState with an Enforce config that has jwks_configs populated — + // `build_nip_fi_components` runs and sets `nip_fi_verifier` and + // `nip_fi_jwks_source` on the state. + // 2. Seed the state's own `nip_fi_jwks_source` (no HTTP). + // 3. Call `install_nip_fi_command_components` with + // `state.nip_fi_jwks_source.clone()` — the same Arc the verifier holds. + // 4. Assert `nip_fi_verifier.verify(assertion_jwt)` succeeds. + // + // Mutation evidence: + // Reintroduce a second, unseeded source and pass it to the installer (the + // original bug) → the verifier's source stays cold → verify returns + // `KeySourceUnavailable` → the is_ok() assertion panics. + #[tokio::test] + async fn installer_warmup_makes_assertion_verifier_functional() { + use super::install_nip_fi_command_components; + use crate::nip_fi_config::NipFiRelayConfig; + use buzz_auth::NipFiMode; + + // Build the config with an Enforce NIP-FI section so build_nip_fi_components + // sets nip_fi_verifier + nip_fi_jwks_source on the AppState. + let mut config = crate::config::Config::hermetic_for_test(); + let jwks_configs = vec![test_jwks_config()]; + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let cmd_configs = vec![( + TEST_ISS.to_owned(), + CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(30), + authorized_principals: Some(vec![TEST_SUB.to_owned()]), + deny_set_capacity: Some(100), + }, + )]; + config.nip_fi = NipFiRelayConfig { + mode: NipFiMode::Enforce, + registry: registry.clone(), + jwks_configs: jwks_configs.clone(), + command_configs: cmd_configs.clone(), + max_connection_lifetime_secs: 3600, + }; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + // build_nip_fi_components runs here because mode == Enforce and jwks_configs + // is non-empty; nip_fi_verifier and nip_fi_jwks_source are set on the state. + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + assert!( + state.nip_fi_verifier.is_some(), + "nip_fi_verifier must be Some for Enforce config" + ); + assert!( + state.nip_fi_jwks_source.is_some(), + "nip_fi_jwks_source must be Some for Enforce config" + ); + + // Seed the state's own JWKS source — the exact Arc the verifier holds. + // This is the warmup step that main.rs achieves by passing this same Arc + // to install_nip_fi_command_components. + let state_source = state.nip_fi_jwks_source.as_ref().unwrap(); + state_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + // Clone the source Arc before the mutable borrow of state so the + // borrow checker sees both borrows as non-overlapping. This clone is + // the same operation main.rs performs: it shares the EXACT underlying + // source, not a fresh one. + let shared_source = state.nip_fi_jwks_source.clone().unwrap(); + + // Call the installer with the state's own source (mirrors main.rs after + // the shared-source fix). The warmup loop confirms the seeded snapshot. + let report = install_nip_fi_command_components( + &mut state, + NipFiMode::Enforce, + ®istry, + shared_source, + &jwks_configs, + &cmd_configs, + ) + .await + .expect("install must succeed for valid config"); + assert_eq!( + report.warmed_issuers, 1, + "installer must report 1 warmed issuer (snapshot was pre-seeded)" + ); + + // Assert that a valid assertion JWT now verifies successfully. + // This is the core oracle: the verifier reads key_set() from the same + // Arc that the installer warmed — if they were different Arcs, the + // verifier's source would be cold and this would return KeySourceUnavailable. + let key = nostr::Keys::generate(); + let token = mint_assertion_token(&key.public_key().to_hex()); + let verifier = state.nip_fi_verifier.as_deref().unwrap(); + let result = verifier.verify(&token); + assert!( + result.is_ok(), + "nip_fi_verifier.verify must succeed after installer warmup on the shared source; \ + got: {result:?}" + ); + + state + .shutting_down + .store(true, std::sync::atomic::Ordering::Release); + } + + /// Mint a valid ES256 `nip-fi+jwt` assertion for `nostr_pubkey = key_hex`, + /// signed by the route-integration-test key pair. + /// Used by the startup-oracle test to verify the assertion verifier against + /// its own JWKS source after installer warmup. + fn mint_assertion_token(key_hex: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISS, + "aud": TEST_AUD, + "sub": TEST_SUB, + "iat": now, + "exp": now + 600, + "nostr_pubkey": key_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).expect("valid test EC key"); + encode(&header, &claims, &key).expect("sign assertion-test token") + } + + // ── Issuer scope: the close scans match (issuer, k) ────────────────────── + // + // A deny entry is keyed by (iss, k). The same key K admitted under a + // second issuer B is not blocked by A's entry, so neither the route nor the + // cross-pod consumer may close B's sessions. [FI-TRACE-DENY-SET] + + const OTHER_ISS: &str = "https://idp-b.test.example.com"; + + /// Live root + audio sessions for one (issuer, key), with the tokens the + /// close path cancels. The guard keeps the audio socket registered. + struct IssuerSessions { + root: tokio_util::sync::CancellationToken, + audio: tokio_util::sync::CancellationToken, + _audio_guard: crate::state::CommunityConnectionGuard, + } + + impl IssuerSessions { + fn register(state: &crate::state::AppState, issuer: &str, key: &nostr::PublicKey) -> Self { + use crate::state::CommunityConnectionControl; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()); + let root = CancellationToken::new(); + let conn_id = uuid::Uuid::new_v4(); + state.conn_manager.register( + conn_id, + mpsc::channel(8).0, + mpsc::channel(8).0, + mpsc::channel(1).0, + None, + root.clone(), + community, + Arc::new(std::sync::atomic::AtomicU8::new(0)), + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + CommunityConnectionControl::new(root.clone()), + ); + state.conn_manager.set_authenticated_identity( + conn_id, + key.to_bytes().to_vec(), + Some(issuer.to_owned()), + ); + let audio = CancellationToken::new(); + let ctrl = CommunityConnectionControl::new(audio.clone()); + crate::audio::handler::audio_post_auth_register( + &ctrl, + key.to_bytes().to_vec(), + Some(issuer.to_owned()), + ); + let _audio_guard = + state + .community_connections + .register(uuid::Uuid::new_v4(), community, ctrl); + Self { + root, + audio, + _audio_guard, + } + } + + fn assert_closed(&self, who: &str) { + assert!(self.root.is_cancelled(), "{who}: root session must close"); + assert!(self.audio.is_cancelled(), "{who}: audio session must close"); + } + + fn assert_open(&self, who: &str) { + assert!( + !self.root.is_cancelled(), + "{who}: root session must survive" + ); + assert!( + !self.audio.is_cancelled(), + "{who}: audio session must survive" + ); + } + } + + #[tokio::test] + async fn route_disconnect_closes_only_caller_issuer_sessions() { + let state = build_test_state(1000).await; + let key = nostr::Keys::generate().public_key(); + let under_a = IssuerSessions::register(&state, TEST_ISS, &key); + let under_b = IssuerSessions::register(&state, OTHER_ISS, &key); + + let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": key.to_hex()})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + under_a.assert_closed("issuer A"); + under_b.assert_open("issuer B"); + } + + /// State whose registry and deny map know only issuer A (`TEST_ISS`). + async fn cross_pod_state(capacity: usize) -> Arc { + let mut config = crate::config::Config::hermetic_for_test(); + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip_fi_deny_map = Some(Arc::new(NipFiDenyMap::new( + capacity, + vec![IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity, + }], + ))); + Arc::new(state) + } + + fn cross_pod_message(key: &nostr::PublicKey) -> buzz_pubsub::NipFiDisconnect { + buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: key.to_bytes().to_vec(), + until_unix: (chrono::Utc::now() + chrono::Duration::seconds(300)).timestamp(), + until_unix_nanos: 0, + } + } + + /// Delivers an A-issued cross-pod message for K with A and B sessions + /// live, and asserts the merge outcome and that only A's sessions close. + fn assert_cross_pod_closes_only_issuer_a( + state: &crate::state::AppState, + expected: buzz_auth::CrossPodMergeResult, + ) { + let key = nostr::Keys::generate().public_key(); + let under_a = IssuerSessions::register(state, TEST_ISS, &key); + let under_b = IssuerSessions::register(state, OTHER_ISS, &key); + + let result = apply_nip_fi_disconnect(state, &cross_pod_message(&key), chrono::Utc::now()); + + assert_eq!(result, NipFiDisconnectApplyResult::Applied(expected)); + under_a.assert_closed("issuer A"); + under_b.assert_open("issuer B"); + } + + #[tokio::test] + async fn cross_pod_merged_closes_only_message_issuer_sessions() { + let state = cross_pod_state(10).await; + assert_cross_pod_closes_only_issuer_a(&state, buzz_auth::CrossPodMergeResult::Merged); + } + + #[tokio::test] + async fn cross_pod_capacity_exceeded_closes_only_message_issuer_sessions() { + let state = cross_pod_state(1).await; + let filler = nostr::Keys::generate().public_key(); + assert_eq!( + apply_nip_fi_disconnect(&state, &cross_pod_message(&filler), chrono::Utc::now()), + NipFiDisconnectApplyResult::Applied(buzz_auth::CrossPodMergeResult::Merged), + ); + assert_cross_pod_closes_only_issuer_a( + &state, + buzz_auth::CrossPodMergeResult::CapacityExceeded, + ); + } + + #[tokio::test] + async fn cross_pod_shard_poisoned_closes_only_message_issuer_sessions() { + let state = cross_pod_state(10).await; + state + .nip_fi_deny_map + .as_deref() + .expect("deny map present") + .poison_shard_for_test(TEST_ISS); + assert_cross_pod_closes_only_issuer_a( + &state, + buzz_auth::CrossPodMergeResult::ShardPoisoned, + ); + } +} diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 2c49ca6a5c3..e8b5354480e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -572,7 +572,7 @@ mod postgres_tests { } async fn operator_test_state(operator_keys: &[Keys]) -> Option> { - let mut config = crate::config::Config::from_env().ok()?; + let mut config = crate::config::Config::hermetic_for_test(); config.database_url = crate::test_support::database_url(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = "wss://tenant.example".to_string(); @@ -1265,7 +1265,7 @@ mod postgres_tests { async fn provisioning_fails_closed_when_origin_unset_but_pubkeys_set() { let operator = Keys::generate(); - let mut config = crate::config::Config::from_env().expect("default config loads"); + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_operator_pubkeys = vec![operator.public_key().to_hex()]; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 6e6d467d092..cdb9a4a6e78 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -18,7 +18,7 @@ use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket}; use axum::http::{HeaderMap, StatusCode}; use axum::{ - extract::{Path, State, WebSocketUpgrade}, + extract::{FromRequest, Path, State, WebSocketUpgrade}, response::IntoResponse, }; use bytes::Bytes; @@ -30,9 +30,8 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use buzz_auth::generate_challenge; +use buzz_auth::{generate_challenge, VerifiedAssertion}; use buzz_core::tenant::TenantContext; -use buzz_db::channel::MemberRole; use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; @@ -65,8 +64,23 @@ pub async fn ws_audio_handler( State(state): State>, Path(channel_id): Path, headers: HeaderMap, - ws: WebSocketUpgrade, + req: axum::extract::Request, ) -> impl IntoResponse { + // NIP-FI assertion check at upgrade — before tenant lookup and before the + // WebSocket handshake. Running pre-lookup means a denied request pays zero + // DB cost and the gate is reachable in tests without a live community. + // [FI-TRACE-TRANSPORT-CLOSED] [NIP-FI.md §Admission pairing sequence] + let nip_fi_assertion = { + use crate::nip_fi_upgrade::{check_nip_fi_at_upgrade, NipFiUpgradeOutcome}; + let mode = state.config.nip_fi.mode; + let verifier = state.nip_fi_verifier.as_deref(); + match check_nip_fi_at_upgrade(&headers, verifier, mode) { + NipFiUpgradeOutcome::NotRequired => None, + NipFiUpgradeOutcome::Admitted(assertion) => Some(assertion), + NipFiUpgradeOutcome::Denied(resp) => return resp.into_response(), + } + }; + // Row zero: bind this huddle-audio connection to its community from the // request host BEFORE the WebSocket upgrade, identical to the main relay // door. An unmapped host or lookup failure fails closed with a generic 404 @@ -87,6 +101,11 @@ pub async fn ws_audio_handler( } }; + let ws = match WebSocketUpgrade::from_request(req, &state).await { + Ok(ws) => ws, + Err(e) => return e.into_response(), + }; + let permit = match acquire_audio_connection_permit(&state.conn_semaphore) { Some(permit) => permit, None => { @@ -102,8 +121,20 @@ pub async fn ws_audio_handler( // Keep the parser boundary at the largest message this route accepts. The // checks in the receive loop still distinguish text from binary policy, but // they run after tungstenite has assembled a message. + // Capture the upgrade instant here — before the on_upgrade callback fires — + // so the NIP-FI session partition is rooted at the HTTP handshake, not the + // post-community-active-check instant. [FI-TRACE-LEASE-BOUND] + let connection_time = chrono::Utc::now(); limit_audio_websocket(ws).on_upgrade(move |socket| { - handle_audio_connection(socket, state, tenant, channel_id, permit) + handle_audio_connection( + socket, + state, + tenant, + channel_id, + permit, + nip_fi_assertion, + connection_time, + ) }) } @@ -147,6 +178,8 @@ async fn handle_audio_connection( tenant: TenantContext, channel_id: Uuid, _permit: OwnedSemaphorePermit, + nip_fi_assertion: Option, + connection_time: chrono::DateTime, ) { let cancel = CancellationToken::new(); let control = CommunityConnectionControl::new(cancel); @@ -161,31 +194,66 @@ async fn handle_audio_connection( control, move || async move { check_state.db.is_community_active(community_id).await }, move |control| { - handle_active_audio_connection(socket, run_state, tenant, channel_id, control) + handle_active_audio_connection( + socket, + run_state, + tenant, + channel_id, + control, + nip_fi_assertion, + connection_time, + ) }, ) .await; } -async fn handle_active_audio_connection( +/// Records the NIP-42-proven pubkey and its admitting NIP-FI issuer on an audio +/// control after successful auth so the issuer-scoped NIP-FI disconnect scan +/// can reach audio sockets alongside relay peers. +/// +/// Extracted from `handle_active_audio_connection` so tests can register audio +/// connections through the same production seam without spinning up full audio +/// infrastructure. +pub(crate) fn audio_post_auth_register( + control: &CommunityConnectionControl, + pubkey_bytes: Vec, + nip_fi_issuer: Option, +) { + control.set_proven_identity(pubkey_bytes, nip_fi_issuer); +} + +pub(crate) async fn handle_active_audio_connection( socket: WebSocket, state: Arc, tenant: TenantContext, channel_id: Uuid, control: CommunityConnectionControl, + nip_fi_assertion: Option, + connection_time: chrono::DateTime, ) { let cancel = control.cancellation_token(); let disconnect_reason = control.disconnect_reason(); + // connection_time is threaded in from the HTTP handler (captured immediately + // before on_upgrade) so the session partition is rooted at the true upgrade + // instant, not the post-community-active-check instant. [FI-TRACE-LEASE-BOUND] let (mut ws_send, mut ws_recv) = socket.split(); let challenge = generate_challenge(); let challenge_msg = serde_json::json!({"type": "challenge", "challenge": challenge}).to_string(); - if ws_send - .send(WsMessage::Text(challenge_msg.into())) - .await - .is_err() - { + // Bounded send: pre-registration exits must not block on a stalled + // sink indefinitely. Check Ok(Ok(())) — is_err() would swallow + // Ok(Err(sink_error)) and allow the handler to continue with a broken + // sink. [R2: bounded delivery — consistent policy; MINOR fix] + if !matches!( + tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text(challenge_msg.into())), + ) + .await, + Ok(Ok(())) + ) { return; } @@ -231,13 +299,17 @@ async fn handle_active_audio_connection( Ok(ctx) => ctx, Err(e) => { warn!(channel_id = %channel_id, "audio auth failed: {e}"); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-registration exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({"type":"error","message":"auth failed"}) .to_string() .into(), - )) - .await; + )), + ) + .await; return; } }; @@ -247,6 +319,354 @@ async fn handle_active_audio_connection( let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; + // NIP-FI key pairing [FI-INV-05]: unconditional, using the shared production + // seam. When an assertion was presented at upgrade, the proven NIP-42 key + // MUST equal the assertion's `nostr_pubkey` claim. Claimless assertion is + // also a denial. The seam owns verdict, frame delivery, metric, and cancel. + // [FI-TRACE-DENIAL-ORACLE post-establishment] + if crate::nip_fi_session::enforce_nip_fi_key_pairing( + nip_fi_assertion.as_ref(), + pubkey, + crate::nip_fi_session::PairingDenialTarget::Audio { + ws_send: &mut ws_send, + cancel: &cancel, + channel_id, + }, + ) + .await + == crate::nip_fi_session::PairingOutcome::Denied + { + return; + } + + // Create the terminal channel and register the sender on the control + // BEFORE audio_post_auth_register makes the pubkey scan-visible. + // + // Ordering invariant: by the time any concurrent disconnect_nip_fi scan + // can find this connection (proven_pubkey is set), terminal_frame_tx is + // already registered. A scan that fires between registration and the + // deny-set check below will find both the pubkey AND the sender, so the + // denial payload is enqueued and the client gets payload-then-close. + // + // The receiver (terminal_ctrl_rx) is created here too; it is consumed by + // the check_cancel!() drain arms and later moved into the send_loop. + // [FI-TRACE-ADMIN-DISCONNECT-PAYLOAD] + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = + tokio::sync::mpsc::channel::(1); + control.set_terminal_frame_sender(terminal_ctrl_tx.clone()); + + // Register the proven pubkey with the registry AFTER successful pairing + // (and AFTER the terminal sender is registered above) so the spec sequence + // (NIP-FI.md:217-233) is proof → equality → register → deny check. + // A pre-pairing registration would admit an unproven key into the + // close-scan scope. The terminal sender is registered first so that any + // concurrent disconnect that observes this session after the pubkey write + // can always enqueue the denial frame. [FI-TRACE-ADMIN-DISCONNECT-PAYLOAD] + audio_post_auth_register( + &control, + pubkey_bytes.clone(), + nip_fi_assertion + .as_ref() + .map(|a| a.identity().issuer().to_owned()), + ); + + // Step 6 (NIP-FI.md:227-233): deny-set check — runs AFTER registration + // (audio_post_auth_register above) so any concurrent disconnect either sees + // this audio session in the close scan OR we see the deny entry here. + // Both sides of the straddle are covered; neither side can miss. + // [FI-TRACE-DENY-SET] + // + // Test hook: fires immediately after registration and before the deny-set + // check so a straddle test can insert a deny entry in the exact window. + // No-op in production. [nip_fi_test_hooks::deny_set_check_hook, W_audio_deny] + #[cfg(test)] + crate::nip_fi_test_hooks::before_deny_set_check(tenant.community()).await; + if let Some(assertion) = &nip_fi_assertion { + if let Some(asserted_key) = assertion.asserted_key() { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if deny_map.is_denied( + assertion.identity().issuer(), + &asserted_key, + chrono::Utc::now(), + ) { + warn!( + channel_id = %channel_id, + pubkey = %pubkey_hex, + "NIP-FI deny-set hit at audio post-registration check — denying" + ); + use futures_util::SinkExt as _; + // Bounded sends: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _deny_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let _ = tokio::time::timeout_at( + _deny_deadline, + ws_send.send(crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + )), + ) + .await; + // Send explicit 1008 POLICY close frame; send_loop not yet + // started so ws_send is directly owned. [FI-TRACE-CLOSE-CODE] + let _ = tokio::time::timeout_at( + _deny_deadline, + ws_send.send(axum::extract::ws::Message::Close(Some( + axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: axum::extract::ws::Utf8Bytes::from_static( + "authorization denied", + ), + }, + ))), + ) + .await; + cancel.cancel(); + return; + } + } + } + } + + // Test hook: fires immediately AFTER the deny-set check block when the key + // was NOT denied (absent or off-mode). Proves the handler reached the + // post-check/membership gate for a clean key. No-op in production. + // [nip_fi_test_hooks::audio_after_deny_check_passed_hook, W_audio_deny_absent] + #[cfg(test)] + crate::nip_fi_test_hooks::after_deny_set_check_passed(tenant.community()).await; + + // Compute the NIP-FI session deadline (same three-term formula as main relay). + // Partition is rooted at `connection_time` captured before NIP-42 auth. + // [FI-TRACE-LEASE-BOUND] + let audio_session_deadline = nip_fi_assertion.as_ref().map(|a| { + crate::connection::compute_session_deadline( + a, + connection_time, + state.config.nip_fi.max_connection_lifetime(), + ) + }); + + // B1: Arm the NIP-FI expiry task HERE — before any persisting side effect + // (relay membership, room join, roster events, PARTICIPANT_JOINED). + // + // Create the session admission gate when in enforce mode. The gate is the + // quiescence barrier: commit_participant_join acquires an effect permit + // before committing the 48101 + membership transaction. The expiry task's + // gate.expire() holds the write guard until all pre-expiry permits finish. + // + // terminal_ctrl_tx / terminal_ctrl_rx were created and registered above + // (before audio_post_auth_register) so that any concurrent disconnect that + // observes this session always finds a registered sender. The send_loop + // (spawned below) takes ownership of terminal_ctrl_rx and drains it on + // cancellation. [FI-TRACE-LEASE-BOUND, FI-TRACE-ADMIN-DISCONNECT-PAYLOAD] + + // One gate per audio connection (one-gate-per-connection invariant). + // Enforce mode: gate has a deadline; expiry task fires at that deadline. + // Off-mode: off_mode() gate never self-expires; acquire_effect always succeeds. + let audio_gate = if let Some(deadline) = audio_session_deadline { + crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()) + } else { + crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()) + }; + + let mut _nip_fi_admission_expiry = audio_session_deadline.map(|deadline| { + crate::nip_fi_session::spawn_nip_fi_expiry_task( + deadline, + std::sync::Arc::clone(&audio_gate), + terminal_ctrl_tx.clone(), + crate::nip_fi_session::NipFiWsRoute::Audio, + control.clone(), + ) + }); + + // Already-expired check: the synchronous guard catches a deadline that is + // already past at this instant, without relying on the async expiry task + // to execute first. Sends the denial frame directly on ws_send (still + // owned — send_loop has not started) then cancels and returns. + if let Some(deadline) = audio_session_deadline { + if chrono::Utc::now() >= deadline { + warn!( + channel_id = %channel_id, + pubkey = %pubkey_hex, + "NIP-FI session deadline already expired at pairing — rejecting audio admission" + ); + use futures_util::SinkExt as _; + // Bounded sends: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _expired_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let _ = tokio::time::timeout_at( + _expired_deadline, + ws_send.send(crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + )), + ) + .await; + // Send explicit 1008 POLICY close frame; send_loop not yet + // started so ws_send is directly owned. [FI-TRACE-CLOSE-CODE] + let _ = tokio::time::timeout_at( + _expired_deadline, + ws_send.send(axum::extract::ws::Message::Close(Some( + axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: axum::extract::ws::Utf8Bytes::from_static("authorization denied"), + }, + ))), + ) + .await; + cancel.cancel(); + return; + } + } + + // Helper macro: check for NIP-FI mid-admission cancellation, drain the + // terminal channel (which holds the denial frame queued by the expiry + // task), send it via ws_send (still owned), then send the policy close + // frame when a disconnect reason is present, and return. + // Used at every async boundary in the admission sequence below. + macro_rules! check_cancel { + () => { + if cancel.is_cancelled() { + use futures_util::SinkExt as _; + // Bounded sends: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _cc_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while let Ok(msg) = terminal_ctrl_rx.try_recv() { + let _ = tokio::time::timeout_at(_cc_deadline, ws_send.send(msg)).await; + } + // Emit the policy close frame when a NIP-FI reason is set. + // The send_loop is not yet started so ws_send is directly + // owned here. [FI-TRACE-CLOSE-CODE, Fix-1] + let nip_fi_close_reason = *disconnect_reason.borrow(); + if let Some(reason) = nip_fi_close_reason { + let _ = + tokio::time::timeout_at(_cc_deadline, ws_send.send(reason.close_message())) + .await; + } + return; + } + }; + (cleanup: $cleanup:expr) => { + if cancel.is_cancelled() { + $cleanup; + use futures_util::SinkExt as _; + // Bounded sends: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _cc_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while let Ok(msg) = terminal_ctrl_rx.try_recv() { + let _ = tokio::time::timeout_at(_cc_deadline, ws_send.send(msg)).await; + } + // Emit the policy close frame when a NIP-FI reason is set. + // [FI-TRACE-CLOSE-CODE, Fix-1] + let nip_fi_close_reason = *disconnect_reason.borrow(); + if let Some(reason) = nip_fi_close_reason { + let _ = + tokio::time::timeout_at(_cc_deadline, ws_send.send(reason.close_message())) + .await; + } + return; + } + }; + (release_lease: $lease:expr) => { + if cancel.is_cancelled() { + // Release any acquired lease before returning. Pre-guard path: + // staged_lease may hold a lease that must be released before we + // return, since the guard hasn't been built yet. + if let Some((lease, directory)) = ($lease).take() { + match directory.release(&lease).await { + Ok(_) => {} + Err(e) => { + tracing::warn!("pre-guard staged_lease release failed on cancel: {e}"); + } + } + } + use futures_util::SinkExt as _; + // Bounded sends: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _cc_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while let Ok(msg) = terminal_ctrl_rx.try_recv() { + let _ = tokio::time::timeout_at(_cc_deadline, ws_send.send(msg)).await; + } + // Emit the policy close frame when a NIP-FI reason is set. + // [FI-TRACE-CLOSE-CODE, Fix-1] + let nip_fi_close_reason = *disconnect_reason.borrow(); + if let Some(reason) = nip_fi_close_reason { + let _ = + tokio::time::timeout_at(_cc_deadline, ws_send.send(reason.close_message())) + .await; + } + return; + } + }; + } + + // Shared finalization for explicit pre-writer error exits after terminal + // registration. Serializes with the winning producer by calling + // `lifecycle_cancel()` (which acquires the transition lock, ensuring any + // in-flight producer completes its `try_send` before the drain runs), + // awaits the expiry task so its frame is committed, then drains any denial + // frame already enqueued by a concurrent admin/expiry path and sends the + // matching policy close code. Used at every error return between terminal + // registration and send_loop startup. [R2: invariant boundary — Fix-2] + // + // Unlike check_cancel!() this is unconditional: the handler is returning + // due to its own error, and the race window means a winning denial may or + // may not have enqueued its frame yet. The drain is always safe: an empty + // channel is a no-op; a non-empty channel means a denial won the race and + // its frame must reach the client before this error return. + // + // Sends are bounded by a 1-second flush deadline so a stalled WS sink + // cannot hold the handler indefinitely on an error exit. + // + // Parameters: + // $control — the CommunityConnectionControl for this session + // $expiry — &mut Option> for the admission expiry task + macro_rules! drain_terminal { + ($control:expr, $expiry:expr) => {{ + use futures_util::SinkExt as _; + // Serialization: lifecycle_cancel acquires the transition lock, + // ensuring any producer that has won the reason but not yet called + // try_send completes its enqueue before the drain below runs. + // Without this, the drain could see an empty channel and discard + // the winning denial payload. [R2: producer serialization] + $control.lifecycle_cancel(); + // Await the expiry task so its try_send is committed before we + // drain. take() prevents double-await in later code. + if let Some(t) = $expiry.take() { + let _ = t.await; + } + let _drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while let Ok(msg) = terminal_ctrl_rx.try_recv() { + let _ = tokio::time::timeout_at(_drain_deadline, ws_send.send(msg)).await; + } + let nip_fi_close_reason = *disconnect_reason.borrow(); + if let Some(reason) = nip_fi_close_reason { + let _ = + tokio::time::timeout_at(_drain_deadline, ws_send.send(reason.close_message())) + .await; + } + }}; + } + + // Bounded drain+close after lifecycle_cancel() and expiry-await are already + // called explicitly by the caller. Applies the same 1-second flush deadline + // as drain_terminal! but skips the serialization step. Used at post-guard + // error exits where lifecycle_cancel + expiry.take().await precede this + // in the same code path. [R2: bounded delivery — Fix-2] + macro_rules! finalize_drain { + () => {{ + use futures_util::SinkExt as _; + let _drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while let Ok(msg) = terminal_ctrl_rx.try_recv() { + let _ = tokio::time::timeout_at(_drain_deadline, ws_send.send(msg)).await; + } + let nip_fi_close_reason = *disconnect_reason.borrow(); + if let Some(reason) = nip_fi_close_reason { + let _ = + tokio::time::timeout_at(_drain_deadline, ws_send.send(reason.close_message())) + .await; + } + }}; + } + if crate::api::relay_members::enforce_relay_membership( &state, tenant.community(), @@ -258,18 +678,32 @@ async fn handle_active_audio_connection( .is_err() { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied"); - let _ = ws_send - .send(WsMessage::Text( + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({"type": "error", "message": "restricted: not a relay member"}) .to_string() .into(), - )) - .await; + )), + ) + .await; + // Drain any concurrent denial that raced in while the membership check + // was awaiting — terminal_ctrl_rx holds the denial frame, disconnect_reason + // holds the close code. Diagnostic send above is bounded; drain below + // applies its own 1-second deadline. [R2: bounded delivery] + drain_terminal!(control, _nip_fi_admission_expiry); return; } + // Test hook: fires immediately before the first check_cancel!() so W_FIX1 + // can hold the handler here while the expiry task fires, then release to + // let check_cancel!() drain the terminal channel and emit the policy close. + // No-op in production. [nip_fi_test_hooks::audio_before_first_check_cancel_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_first_audio_check_cancel(tenant.community()).await; + check_cancel!(); // ── Step 3: membership check / auto-add ─────────────────────────────────── - let parent_id_for_event = match ensure_membership( + let membership_admission = match check_membership_for_admission( &state, &tenant, channel_id, @@ -278,19 +712,45 @@ async fn handle_active_audio_connection( ) .await { - Ok(parent_id) => parent_id, + Ok(admission) => admission, Err(e) => { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}"); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({"type":"error","message":"not a member"}) .to_string() .into(), - )) - .await; + )), + ) + .await; + // Test hook: fires after the "not a member" diagnostic frame is sent + // and before drain_terminal!(). Used by the R2 race witness to pause + // the handler here while a concurrent admin fires disconnect_nip_fi + // and its cancel_race_test_hook pauses the producer after winning the + // reason (lock held). Releasing this hook → handler calls + // drain_terminal!() → lifecycle_cancel() blocks on admin's lock → + // admin completes try_send → unblocks → drain delivers denial frame. + // Falsification target: remove lifecycle_cancel() from drain_terminal!(). + // No-op in production. [nip_fi_test_hooks::audio_not_a_member_drain_terminal_hook, R2] + #[cfg(test)] + crate::nip_fi_test_hooks::before_not_a_member_drain_terminal(tenant.community()).await; + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } }; + // Derive parent_id_for_event from the membership admission result. + // This is the channel ID that lifecycle events (48101/48102/48103) belong to. + let parent_id_for_event = match &membership_admission { + MembershipAdmission::Existing { parent_channel_id } => *parent_channel_id, + MembershipAdmission::AutoAddRequired { + parent_channel_id, .. + } => *parent_channel_id, + }; + check_cancel!(); // Huddle cross-pod routing (mesh) OR single-pod guardrail. // @@ -301,20 +761,25 @@ async fn handle_active_audio_connection( // `huddle_audio_available=false` rejection under a non-mesh horizontal // deployment (two peers on different pods would never hear each other). // - // `remote_owner` is `Some` only on the non-owner path; it carries the - // registration to the owner and, once the client is admitted locally, is - // opened so its media forwards to the owner instead of fanning out locally. + // `pending_remote` drives the local vs. remote ownership decision. + // `admission_guard.lease` holds the freshly-acquired Redis lease (if any) + // and its directory for release; it is set here before any other resource + // that could need cleanup, so pre-commit exits always use the guard. let mut pending_remote: Option = None; - // The freshly-acquired owner lease, if this connection won the CAS. Held - // until `add_peer` succeeds, then installed in the owner registry so the - // renewer's lifetime matches the room's, not this connection's failure - // paths (archived channel, version reject, room full) which return early. - let mut acquired_lease: Option = None; + // Temporary staging for the lease+directory before the admission guard is + // constructed (the room isn't available yet at this point). + let mut staged_lease: Option<( + crate::audio::join::HuddleLease, + std::sync::Arc, + )> = None; match state.mesh() { Some(mesh) => { if mesh.owners.is_draining() { - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({ "type": "error", "code": "huddle_relay_draining", @@ -322,8 +787,11 @@ async fn handle_active_audio_connection( }) .to_string() .into(), - )) - .await; + )), + ) + .await; + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } match crate::audio::join::resolve_join_owner_ready( @@ -336,7 +804,11 @@ async fn handle_active_audio_connection( .await { Ok(resolved) => { - acquired_lease = resolved.acquired; + if let Some(lease) = resolved.acquired { + let directory: std::sync::Arc = + std::sync::Arc::new(mesh.directory.clone()); + staged_lease = Some((lease, directory)); + } pending_remote = Some(resolved.outcome); } Err(e) => { @@ -345,8 +817,11 @@ async fn handle_active_audio_connection( pubkey = %pubkey_hex, "huddle join rejected by fence: {e}" ); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({ "type": "error", "code": "join_rejected", @@ -354,11 +829,17 @@ async fn handle_active_audio_connection( }) .to_string() .into(), - )) - .await; + )), + ) + .await; + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } } + // I1 residual: staged_lease may now hold an acquired lease. Release + // it (awaited, not detached) before returning on cancel. + check_cancel!(release_lease: staged_lease); } None => { if !state.config.huddle_audio_available { @@ -367,8 +848,11 @@ async fn handle_active_audio_connection( pubkey = %pubkey_hex, "huddle audio unavailable under horizontal scaling — rejecting join" ); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({ "type": "error", "code": "huddle_audio_unavailable", @@ -376,8 +860,11 @@ async fn handle_active_audio_connection( }) .to_string() .into(), - )) - .await; + )), + ) + .await; + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } } @@ -401,27 +888,50 @@ async fn handle_active_audio_connection( match state.db.get_channel(tenant.community(), channel_id).await { Ok(ch) if ch.archived_at.is_some() => { debug!(channel_id = %channel_id, "channel archived before room join"); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({"type":"error","message":"huddle has ended"}) .to_string() .into(), - )) - .await; + )), + ) + .await; + // I1 residual: release lease with an awaited call, not a detached task. + if let Some((lease, directory)) = staged_lease { + if let Err(e) = directory.release(&lease).await { + tracing::warn!(channel_id = %channel_id, "archived-exit lease release failed: {e}"); + } + } state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } Err(e) => { warn!(channel_id = %channel_id, "pre-join channel check failed (fail-closed): {e}"); + // I1 residual: release lease with an awaited call, not a detached task. + if let Some((lease, directory)) = staged_lease { + if let Err(re) = directory.release(&lease).await { + tracing::warn!(channel_id = %channel_id, "db-error-exit lease release failed: {re}"); + } + } state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } Ok(_) => {} // Channel exists and is not archived — proceed. } + // I1 residual: staged_lease may hold an acquired lease. Release it + // (awaited, not detached) before returning on cancel. + check_cancel!(release_lease: staged_lease); // Reject unsupported future versions up-front so we don't accidentally // pin a room to a version we can't speak. Versions 1..=CURRENT are OK. @@ -434,8 +944,11 @@ async fn handle_active_audio_connection( current = CURRENT_PROTOCOL_VERSION, "audio: client requested unsupported protocol version" ); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({ "type": "error", "code": "unsupported_version", @@ -446,16 +959,38 @@ async fn handle_active_audio_connection( }) .to_string() .into(), - )) - .await; + )), + ) + .await; + if let Some((lease, directory)) = staged_lease { + // I1 residual: release lease with an awaited call, not a detached task. + if let Err(e) = directory.release(&lease).await { + tracing::warn!(channel_id = %channel_id, "version-mismatch-exit lease release failed: {e}"); + } + } + // [R2: pre-writer invariant boundary] + drain_terminal!(control, _nip_fi_admission_expiry); return; } + // Build the admission guard. From this point every pre-commit exit MUST + // call `guard.release_before_commit().await` before returning so that the + // lease, remote registration, and peer are always cleaned up through the + // single shared path (IMPORTANT 1-3). + let mut guard = HuddleAdmissionGuard { + lease: staged_lease, + remote_session: None, + remote_stream: None, + peer_id: None, + room: Arc::clone(&room), + audio_rooms: Arc::clone(&state.audio_rooms), + community: tenant.community(), + channel_id, + }; + // Remote registration happens before ingress admission. The owner-assigned // index is therefore the only index this client ever has; no frame or // `joined` message can escape with an ingress-local placeholder. - let mut remote_session: Option = None; - let mut remote_stream: Option = None; let mut remote_fence: Option> = None; if let (Some(mesh), Some(crate::audio::join::JoinOutcome::RemoteOwner { .. })) = (state.mesh(), pending_remote) @@ -480,17 +1015,34 @@ async fn handle_active_audio_connection( .await { Ok((session, stream)) => { - remote_session = Some(session); - remote_stream = Some(stream); + guard.remote_session = Some(session); + guard.remote_stream = Some(stream); remote_fence = Some(Arc::clone(&mesh.audio_fence)); } Err(crate::audio::join::DialError::Rejected(reason)) => { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner rejected registration: {reason:?}"); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( remote_rejection_ws_error(&reason).to_string().into(), - )) - .await; + )), + ) + .await; + // I3 residual: await expiry task before resource teardown. + // B2: lifecycle_cancel holds the transition lock so any concurrent + // terminal writer that won the reason but hasn't enqueued yet + // completes its try_send before the cancel wakes any consumer. + // [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + guard.release_before_commit().await; + // Drain any denial frame queued by the expiry task. + // Bounded by 1-second deadline. [R2: bounded delivery — Fix-2] + finalize_drain!(); state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); @@ -498,96 +1050,218 @@ async fn handle_active_audio_connection( } Err(crate::audio::join::DialError::Mesh(e)) => { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner registration failed: {e}"); - let _ = ws_send - .send(WsMessage::Text( + // Bounded send: pre-writer exits must not block on a stalled + // sink indefinitely. [R2: bounded delivery — consistent policy] + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( serde_json::json!({ "type": "error", "code": "huddle_owner_unreachable", "message": "could not reach the huddle owner" }) .to_string() .into(), - )) - .await; + )), + ) + .await; + // I3 residual: await expiry task before resource teardown. + // B2: lifecycle_cancel holds the transition lock so any concurrent + // terminal writer that won the reason but hasn't enqueued yet + // completes its try_send before the cancel wakes any consumer. + // [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + guard.release_before_commit().await; + // Drain any denial frame queued by the expiry task. + // Bounded by 1-second deadline. [R2: bounded delivery — Fix-2] + finalize_drain!(); state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); return; } } + // B1: post-dial cancel check — guard runs clean-close + lease release. + // IMPORTANT 3 residual: await expiry task explicitly, do not infer + // completion from cancel.is_cancelled(). + if cancel.is_cancelled() { + // B2: lifecycle_cancel holds the transition lock so a concurrent + // terminal writer completes its try_send before we enter the drain. + // [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + guard.release_before_commit().await; + // Bounded drain+close. [R2: bounded delivery — Fix-2] + finalize_drain!(); + return; + } } - let admission = if let Some(session) = remote_session.as_ref() { - room.add_peer_at_index(pubkey_hex.clone(), requested_version, session.peer_index()) - .map(|(id, _mirror_epoch, audio, ctrl, revision)| { - // Report the owner-assigned epoch, not the local mirror's: - // the mirror never fans out via `broadcast_frame`, so its epoch - // is inert. The client's self-entry must match the owner roster. - ( - id, - session.peer_index(), - session.epoch(), - audio, - ctrl, - revision, - ) - }) - } else { - room.add_peer(pubkey_hex.clone(), requested_version) + // ── Step 5: add_peer under a short gate permit ──────────────────────────── + // The permit spans the real peer insertion (IMPORTANT 2): expiry cannot + // create a peer without winning the gate, so the committed/peer-absent + // invariant holds across deadline-exact races at this seam too. + let add_peer_result = { + // Test hook: fires immediately before audio_gate.acquire_effect() in + // Step 5. Used by F4 add-peer witness: arms this hook, waits for + // arrival (handler at the gate seam), then expires the gate so + // acquire_effect() returns SessionExpired on release. No-op in + // production. [nip_fi_test_hooks::audio_add_peer_gate_acquire_hook, F4] + #[cfg(test)] + crate::nip_fi_test_hooks::before_add_peer_gate_acquire(tenant.community()).await; + let _add_permit = match audio_gate.acquire_effect().await { + Ok(p) => p, + Err(crate::nip_fi_gate::SessionExpired) => { + // Expiry fired before we could add the peer. No peer, no commit. + // F4: Publish the serialized deadline cause BEFORE self-cancelling. + // The expiry task's unbiased select may take the cancellation arm + // (fired by the already-elapsed wall-clock path) and skip + // `expiry_deny_terminal`. Calling it here wins the reason slot + // (or defers to a prior winner) and enqueues the denial frame, so + // the consumer always drains a denial frame before the Close. + // respecting an already-winning cause (first-writer-wins). + // [F4: audio deadline rejection — denial precedes self-cancel] + control.expiry_deny_terminal( + &terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + // IMPORTANT 3 residual: await expiry task explicitly. + // B2: lifecycle_cancel holds the transition lock so any concurrent + // terminal writer that won the reason but hasn't enqueued yet + // completes its try_send before the cancel wakes any consumer. + // [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + guard.release_before_commit().await; + // Bounded drain+close. [R2: bounded delivery — Fix-2] + finalize_drain!(); + return; + } + }; + // Permit is held across add_peer[_at_index] — drop after the call. + if let Some(session) = guard.remote_session.as_ref() { + room.add_peer_at_index(pubkey_hex.clone(), requested_version, session.peer_index()) + .map(|(id, _mirror_epoch, audio, ctrl, revision)| { + ( + id, + session.peer_index(), + session.epoch(), + audio, + ctrl, + revision, + ) + }) + } else { + room.add_peer(pubkey_hex.clone(), requested_version) + } }; let (peer_id, peer_index, peer_epoch, audio_rx, peer_ctrl_rx, admission_revision) = - match admission { + match add_peer_result { Ok(v) => v, Err(crate::audio::room::AdmissionError::Full) => { warn!(channel_id = %channel_id, "audio room participant capacity reached"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"room participant capacity reached"}).to_string().into())).await; - if let (Some(session), Some(stream)) = - (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close( - stream, - session.fenced(), - session.pubkey(), - ) - .await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"room participant capacity reached"}).to_string().into())), + ) + .await; + // IMPORTANT 3: cancel + await expiry task before guard release. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; } + guard.release_before_commit().await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); return; } Err(crate::audio::room::AdmissionError::Ended) => { debug!(channel_id = %channel_id, "room ended before admission"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())).await; - if let (Some(session), Some(stream)) = - (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close( - stream, - session.fenced(), - session.pubkey(), - ) + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_ended","message":"huddle has ended"}).to_string().into())), + ) + .await; + // Test hook: fires after the room_ended error frame is sent and + // before lifecycle_cancel. Used by R2 witness: a concurrent admin + // disconnect fires here (wins reason, enqueues denial frame), then + // the R2 drain below delivers it before the socket closes. + // No-op in production. [nip_fi_test_hooks::before_room_ended_lifecycle_cancel, R2] + #[cfg(test)] + crate::nip_fi_test_hooks::before_room_ended_lifecycle_cancel(tenant.community()) .await; + // IMPORTANT 3: cancel + await expiry task before guard release. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; } + guard.release_before_commit().await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); return; } Err(crate::audio::room::AdmissionError::VersionMismatch { pinned, requested }) => { info!(channel_id = %channel_id, pubkey = %pubkey_hex, pinned, requested, "audio: protocol version mismatch — upgrade required"); - let _ = ws_send.send(WsMessage::Text(serde_json::json!({ - "type": "error", "code": "upgrade_required", - "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), - "pinned_version": pinned, "requested_version": requested, - }).to_string().into())).await; - if let (Some(session), Some(stream)) = - (remote_session.as_ref(), remote_stream.as_mut()) - { - crate::audio::join::send_clean_close( - stream, - session.fenced(), - session.pubkey(), - ) - .await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text(serde_json::json!({ + "type": "error", "code": "upgrade_required", + "message": format!("this huddle is using audio protocol v{pinned}; your client requested v{requested}"), + "pinned_version": pinned, "requested_version": requested, + }).to_string().into())), + ) + .await; + // IMPORTANT 3: cancel + await expiry task before guard release. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; } + guard.release_before_commit().await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); return; } }; + // Record the peer in the guard so any post-add_peer pre-commit exit removes it. + guard.peer_id = Some(peer_id); + + // B1: check for mid-admission expiry immediately after peer is registered + // in the room. The peer_id is now live; cancel means we must undo it. + // + // Test hook: fires after successful add_peer and before the explicit + // `cancel.is_cancelled()` branch below. A test can set cancel here to prove + // that branch's `guard.release_before_commit()` (remove_peer + + // cleanup_if_empty) runs before the handler returns. + // [nip_fi_test_hooks::audio_add_peer_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::after_add_peer(tenant.community()).await; + if cancel.is_cancelled() { + // IMPORTANT 3 residual: do NOT infer expiry-task completion from + // cancel.is_cancelled(). `gate.expire()` calls cancel.cancel() *before* + // its write-lock quiescence barrier (nip_fi_gate.rs). Cancel + await + // the expiry task before releasing any resource so teardown cannot race + // outstanding pre-expiry permits. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + guard.release_before_commit().await; + // Bounded drain+close. [R2: bounded delivery — Fix-2] + finalize_drain!(); + return; + } + info!( channel_id = %channel_id, pubkey = %pubkey_hex, @@ -595,12 +1269,21 @@ async fn handle_active_audio_connection( "audio peer joined" ); - // Owner path: install (or reuse) this room's single lease renewer now that - // a peer is admitted, and capture its owner-loss signal. The connection - // that won the CAS holds `acquired_lease`; it installs the renewer. A - // steady-state owner (an earlier joiner installed it) reuses the room's - // existing signal. `owner_lost` drives this connection's own teardown - // below; `owner_generation` fences the release on room-empty so a stale + // Owner path: record the owner generation and (for the steady-state reuse + // arm) subscribe to the existing owner-loss signal. The lease is NOT + // transferred here — `guard` still holds it so every pre-commit exit goes + // through `guard.release_before_commit()` which directly awaits + // `directory.release()`. The lease transfers into `HuddleOwnerRegistry` + // only after commit succeeds (I1 mandated: transfer-after-commit-won). + // + // Acquire arm (new CAS winner): the lease stays in the guard through all + // pre-commit exits. `owner_lost` / `owner_draining` are populated at the + // commit-won point below when `attach_signals` is called. + // + // Reuse arm (steady-state owner): the registry entry is already live. + // Subscribe to the existing signals here so that a pre-commit cancel + // (expiry, version mismatch, etc.) still tears down this connection + // correctly. `owner_generation` fences room-empty release so a stale // teardown cannot release a newer epoch a re-acquire installed. // // The reuse arm's live entry is guaranteed by `resolve_join_owner_ready`: @@ -615,16 +1298,15 @@ async fn handle_active_audio_connection( let mut owner_draining: Option = None; let mut owner_generation: Option = None; if let Some(mesh) = state.mesh() { - match (pending_remote, acquired_lease.take()) { - (Some(crate::audio::join::JoinOutcome::LocalOwner { generation }), Some(lease)) => { - let signals = - mesh.owners - .attach_signals(channel_id, Arc::new(mesh.directory.clone()), lease); - owner_lost = Some(signals.lost); - owner_draining = Some(signals.draining); + match pending_remote { + Some(crate::audio::join::JoinOutcome::LocalOwner { generation }) + if guard.lease.is_some() => + { + // Acquire arm: lease stays in guard; signals populated post-commit. owner_generation = Some(generation); } - (Some(crate::audio::join::JoinOutcome::LocalOwner { generation }), None) => { + Some(crate::audio::join::JoinOutcome::LocalOwner { generation }) => { + // Reuse arm: subscribe to the existing registry signals. owner_lost = mesh.owners.lost_for(channel_id); owner_draining = mesh.owners.drain_for(channel_id); owner_generation = Some(generation); @@ -644,7 +1326,7 @@ async fn handle_active_audio_connection( // Remote registration and owner-assigned ingress admission completed above. let (peers_snapshot, roster_revision): (Vec, u64) = if let Some(session) = - remote_session.as_ref() + guard.remote_session.as_ref() { ( session @@ -672,6 +1354,27 @@ async fn handle_active_audio_connection( }; debug_assert!(roster_revision >= admission_revision); + // ── Step 6: commit kind:48101 (PARTICIPANT_JOINED) atomically ──────────── + // commit_participant_join takes one DB transaction containing: + // - auto-membership insert (if AutoAddRequired and still absent), and + // - the 48101 event insert + // Both commit under a single session effect permit, or both roll back on + // expiry. Fan-out AND the `joined` publication both happen while the permit + // is still held (IMPORTANT 5: joined inside the permit). + // + // joined-ordering: the `joined` frame is sent to the connecting client and + // broadcast to existing peers ONLY after commit-won. This matches Thufir's + // design (fd00e6fe): no client-visible join success before `48101` commit. + // Client compatibility: clients treat WS close as "leave audio"; receiving + // close without a prior `joined` is a safe no-op — the session never + // stabilised from the client's perspective. + let lifecycle_revision = if guard.remote_session.is_some() { + roster_revision + } else { + admission_revision + }; + + // Build the joined frame now (before moving guard fields into the commit). let joined_msg = serde_json::json!({ "type": "joined", "revision": roster_revision, @@ -682,42 +1385,223 @@ async fn handle_active_audio_connection( }) .to_string(); - if remote_session.is_some() { - if ws_send - .send(WsMessage::Text(joined_msg.into())) - .await - .is_err() - { + match commit_participant_join( + &state, + &tenant, + channel_id, + parent_id_for_event, + &pubkey_hex, + &pubkey_bytes, + peer_id, + lifecycle_revision, + &lifecycle_generation, + &membership_admission, + &audio_gate, + joined_msg, + &room, + ) + .await + { + Ok(CommitJoinOutcome::JoinedSent) => { + // `joined` was broadcast inside the permit — normal flow. + } + Ok(CommitJoinOutcome::JoinedSendFailed) => { + // Committed but the joining peer's ctrl channel was saturated. + // Route through normal admitted teardown: remove peer, emit 48102, + // send remote close. Committed join => exactly one leave. + // + // I1: the lease is still guard-owned (attach_signals was not called). + // Take the peer_id from the guard now so release_before_commit does + // not double-remove, then release the lease at the end of this arm. + let _ = guard.take_peer_id(); room.remove_peer(peer_id); state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); + if let (Some(session), Some(ref mut stream)) = ( + guard.take_remote_session().as_ref(), + guard.take_remote_stream().as_mut(), + ) { + crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()) + .await; + } + // Emit 48102 — committed join produces exactly one leave. + emit_participant_event( + &state, + &tenant, + channel_id, + parent_id_for_event, + ParticipantLifecycle { + kind: Kind::Custom(48102), + participant_pubkey: &pubkey_hex, + roster_revision: None, + admission_id: Some(peer_id), + generation: &lifecycle_generation, + }, + ) + .await; + state + .audio_rooms + .cleanup_if_empty(tenant.community(), channel_id); + // Release the guard-owned lease (peer_id and remote already taken above). + guard.release_before_commit().await; + return; + } + Err(JoinCommitError::Expired) => { + // Gate denied — expiry fired before commit. No `joined` frame was + // sent — commit-won invariant holds. + // + // F4: Publish the serialized deadline cause BEFORE self-cancelling. + // The expiry task's unbiased select may take the cancellation arm + // (wall-clock deadline already elapsed) and return without calling + // `expiry_deny_terminal`. Calling it here wins the reason slot + // (or defers to a prior winner), ensuring the denial frame is queued + // before cancel fires. [F4: audio commit rejection] + control.expiry_deny_terminal( + &terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + // + // IMPORTANT 3 residual: `acquire_effect()` can return `SessionExpired` + // via the deadline fast path (Utc::now() >= deadline) before the + // spawned expiry task completes. Cancel + await the task explicitly — + // do not infer task completion from SessionExpired. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + // I1: lease is still guard-owned (attach_signals not yet called). + // `guard.release_before_commit()` directly awaits directory.release(). + guard.release_before_commit().await; + // Drain the terminal denial frame (already queued by expiry task). + // Bounded by 1-second deadline. [R2: bounded delivery — Fix-2] + finalize_drain!(); + return; + } + Err(JoinCommitError::Archived) => { + // Channel archived between pre-join check and commit (IMPORTANT 4). + // No `joined` frame was sent — commit-won invariant holds. + debug!(channel_id = %channel_id, "channel archived before join commit"); + // IMPORTANT 3: cancel + await expiry task before peer/room teardown. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + // I1: lease is still guard-owned; guard.release_before_commit() releases it. + guard.release_before_commit().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( + serde_json::json!({"type":"error","message":"huddle has ended"}) + .to_string() + .into(), + )), + ) + .await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); + return; + } + Err(JoinCommitError::ParentMembershipLost) => { + // Parent membership revoked between pre-join check and commit (IMPORTANT 4). + // No `joined` frame was sent — commit-won invariant holds. + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "parent membership lost before join commit"); + // IMPORTANT 3: cancel + await expiry task before peer/room teardown. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + // I1: lease is still guard-owned; guard.release_before_commit() releases it. + guard.release_before_commit().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( + serde_json::json!({"type":"error","message":"error: not a member"}) + .to_string() + .into(), + )), + ) + .await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); + return; + } + Err(JoinCommitError::HuddleLinkGone) => { + // Creator-signed huddle_started link deleted between pre-join check + // and commit (IMPORTANT 4 residual: third carried fact). + // No `joined` frame was sent — commit-won invariant holds. + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle_started link gone before join commit"); + // IMPORTANT 3: cancel + await expiry task before peer/room teardown. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + // I1: lease is still guard-owned; guard.release_before_commit() releases it. + guard.release_before_commit().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( + serde_json::json!({"type":"error","message":"huddle has ended"}) + .to_string() + .into(), + )), + ) + .await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); + return; + } + Err(JoinCommitError::Db(e)) => { + // DB failure during join commit — treat same as pre-admission error. + // No `joined` frame was sent — commit-won invariant holds. + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "48101 commit failed: {e}"); + // IMPORTANT 3: cancel + await expiry task before peer/room teardown. + // B2: lifecycle_cancel holds transition lock. [FI-TRACE-CANCEL-RACE, B2 fix] + control.lifecycle_cancel(); + if let Some(t) = _nip_fi_admission_expiry.take() { + let _ = t.await; + } + // I1: lease is still guard-owned; guard.release_before_commit() releases it. + guard.release_before_commit().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + ws_send.send(WsMessage::Text( + serde_json::json!({"type":"error","message":"error: join commit failed"}) + .to_string() + .into(), + )), + ) + .await; + // R2: bounded drain+close. [FI-INV-05, R2 fix — bounded delivery] + finalize_drain!(); return; } - } else { - room.broadcast_control(joined_msg); } - // ── Step 6: emit kind:48101 (PARTICIPANT_JOINED) ────────────────────────── - let lifecycle_revision = if remote_session.is_some() { - roster_revision - } else { - admission_revision - }; - emit_participant_event( - &state, - &tenant, - channel_id, - parent_id_for_event, - ParticipantLifecycle { - kind: Kind::Custom(48101), - participant_pubkey: &pubkey_hex, - roster_revision: Some(lifecycle_revision), - admission_id: Some(peer_id), - generation: &lifecycle_generation, - }, - ) - .await; + // Commit-won. Take guard fields into the live runtime — any remaining + // fields in guard at this point would be double-released on drop, but all + // fields were taken by commit_participant_join above. + let mut remote_session = guard.take_remote_session(); + let remote_stream = guard.take_remote_stream(); + let _ = guard.take_peer_id(); // peer_id was taken for the commit path + + // I1 mandated: transfer-after-commit-won. Now that the join is committed, + // take the lease from the guard and install the registry renewer. Every + // exit after this point is in the live runtime (no pre-commit resources + // to unwind). The room-empty release below (fenced by `owner_generation`) + // is the only release path from here. + if let (Some(mesh), Some((lease, directory))) = (state.mesh(), guard.take_lease()) { + let signals = mesh.owners.attach_signals(channel_id, directory, lease); + owner_lost = Some(signals.lost); + owner_draining = Some(signals.draining); + } + + // B1: After commit_participant_join, the admission is committed. No further + // check_cancel! is needed — the send_loop owns terminal_ctrl_rx from here. let missed_pongs = Arc::new(AtomicU8::new(0)); @@ -726,18 +1610,23 @@ async fn handle_active_audio_connection( let (data_tx, data_rx) = mpsc::channel::(16); let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + // The terminal channel was created before admission (above) so that + // mid-admission expiry could drain it via ws_send. Now the send_loop takes + // ownership of `terminal_ctrl_rx` and drains it in its cancel branch. + // The expiry task (_nip_fi_admission_expiry) armed above is the lifetime + // enforcer for this connection — no second task is needed. let send_cancel = cancel.child_token(); let send_task = tokio::spawn(send_loop( ws_send, data_rx, ctrl_rx, + terminal_ctrl_rx, send_cancel, disconnect_reason, )); - let hb_cancel = cancel.clone(); let hb_missed = Arc::clone(&missed_pongs); - let heartbeat_task = tokio::spawn(heartbeat_loop(ctrl_tx.clone(), hb_missed, hb_cancel)); + let heartbeat_task = tokio::spawn(heartbeat_loop(ctrl_tx.clone(), hb_missed, control.clone())); let fwd_cancel = cancel.child_token(); let forward_task = tokio::spawn(audio_forward_loop( @@ -746,9 +1635,14 @@ async fn handle_active_audio_connection( data_tx, ctrl_tx.clone(), fwd_cancel, - cancel.clone(), + control.clone(), )); + // NIP-FI session-lifetime enforcement task was armed before admission + // (at audio_session_deadline above) with `terminal_ctrl_tx`. Keep the + // handle alive for the duration of the connection. [FI-TRACE-LEASE-BOUND] + let nip_fi_audio_expiry_task = _nip_fi_admission_expiry; + // Non-owner path: own the owner's `HuddleControl` stream in a reader task. // It races the owner's teardown signal against our own cancellation: // * owner speaks first (`Goodbye` / stream close) → tear the client down @@ -760,6 +1654,7 @@ async fn handle_active_audio_connection( // `UnregisterPeer` + `Goodbye(SessionEnded)` so the owner drops us. let reader_task = remote_stream.map(|mut stream| { let reader_cancel = cancel.clone(); + let reader_control = control.clone(); let fence = remote_fence.expect("remote_fence set whenever remote_stream is"); let fenced = remote_session .as_ref() @@ -784,7 +1679,7 @@ async fn handle_active_audio_connection( roster_revision, &roster_ctrl_tx, ) => { - teardown_remote_huddle(cause, channel_id, &reader_cancel, &fence); + teardown_remote_huddle(cause, channel_id, &reader_control, &fence); } _ = reader_cancel.cancelled() => { crate::audio::join::send_clean_close(&mut stream, fenced, &pubkey).await; @@ -806,6 +1701,7 @@ async fn handle_active_audio_connection( .audio_fence, ); let owner_cancel = cancel.clone(); + let owner_control = control.clone(); Some(tokio::spawn(async move { let lost_fired = async { match &owner_lost { @@ -825,7 +1721,7 @@ async fn handle_active_audio_connection( channel_id = %channel_id, "huddle owner is draining — closing local client for rejoin" ); - owner_cancel.cancel(); + owner_control.lifecycle_cancel(); fence.forget(channel_id); } _ = lost_fired => { @@ -833,7 +1729,7 @@ async fn handle_active_audio_connection( channel_id = %channel_id, "huddle owner lost its lease — closing local client for rejoin" ); - owner_cancel.cancel(); + owner_control.lifecycle_cancel(); fence.forget(channel_id); } _ = owner_cancel.cancelled() => {} @@ -855,7 +1751,7 @@ async fn handle_active_audio_connection( ) .await; - cancel.cancel(); + control.lifecycle_cancel(); let _ = send_task.await; let _ = heartbeat_task.await; let _ = forward_task.await; @@ -869,7 +1765,9 @@ async fn handle_active_audio_connection( if let Some(owner_teardown_task) = owner_teardown_task { let _ = owner_teardown_task.await; } - + if let Some(expiry_task) = nip_fi_audio_expiry_task { + let _ = expiry_task.await; + } // Atomic owner remove + end check: remove_peer_and_check_ended holds the // AdmissionGuard lock across index recycling AND the is_empty + ended=true // check. Ingress mirrors never archive authoritative huddle state; they @@ -998,7 +1896,7 @@ async fn handle_active_audio_connection( fn teardown_remote_huddle( cause: crate::audio::join::HuddleTeardownCause, channel_id: Uuid, - cancel: &CancellationToken, + control: &CommunityConnectionControl, fence: &crate::audio::mesh::GenerationFloor, ) { info!( @@ -1006,7 +1904,7 @@ fn teardown_remote_huddle( ?cause, "owner tore down cross-pod huddle session — closing client for rejoin" ); - cancel.cancel(); + control.lifecycle_cancel(); fence.forget(channel_id); } @@ -1157,10 +2055,11 @@ async fn recv_loop( /// /// Control frames (Ping, Pong, Close, control JSON) are drained first on every /// iteration, so heartbeat pings are never starved by audio backpressure. -async fn send_loop( +pub(crate) async fn send_loop( mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, + mut terminal_ctrl_rx: mpsc::Receiver, cancel: CancellationToken, disconnect_reason: watch::Receiver>, ) where @@ -1177,6 +2076,24 @@ async fn send_loop( tokio::select! { biased; _ = cancel.cancelled() => { + // Drain the terminal NIP-FI denial frame first (if any), then + // ordinary control frames, before closing. Mirrors the root + // relay send_loop idiom. The terminal channel has capacity 1 + // and is written before cancel() fires, so it is always + // available when denial is enqueued — even when ctrl_rx + // (capacity 8) is full. Without this drain the biased cancel + // branch sends Close first and the client never sees the + // required denial frame. + while let Ok(terminal_msg) = terminal_ctrl_rx.try_recv() { + if ws_send.send(terminal_msg).await.is_err() { + return; + } + } + while let Ok(ctrl_msg) = ctrl_rx.try_recv() { + if ws_send.send(ctrl_msg).await.is_err() { + return; + } + } let close = disconnect_reason .borrow() .map_or(WsMessage::Close(None), |reason| reason.close_message()); @@ -1205,7 +2122,7 @@ async fn audio_forward_loop( data_tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, - connection_cancel: CancellationToken, + connection_control: CommunityConnectionControl, ) { loop { tokio::select! { @@ -1219,12 +2136,12 @@ async fn audio_forward_loop( // State-bearing roster control may not be dropped. // Closing the connection forces admission to replay // a fresh authoritative snapshot. - connection_cancel.cancel(); + connection_control.lifecycle_cancel(); break; } } Some(PeerCtrl::Close) | None => { - connection_cancel.cancel(); + connection_control.lifecycle_cancel(); break; } } @@ -1244,38 +2161,189 @@ async fn audio_forward_loop( async fn heartbeat_loop( ws_tx: mpsc::Sender, missed_pongs: Arc, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL); loop { + let cancelled = control.cancellation_token(); tokio::select! { _ = interval.tick() => { // fetch_add returns the previous value; +1 gives the current count. let missed = missed_pongs.fetch_add(1, Ordering::Relaxed) + 1; if missed >= MAX_MISSED_PONGS { warn!("audio: {missed} missed pongs — closing connection"); - cancel.cancel(); + control.lifecycle_cancel(); break; } if ws_tx.try_send(WsMessage::Ping(axum::body::Bytes::new())).is_err() { - cancel.cancel(); + control.lifecycle_cancel(); break; } } - _ = cancel.cancelled() => break, + _ = cancelled.cancelled() => break, + } + } +} + +/// Outcome of [`check_membership_for_admission`]. +/// +/// `Existing` means the caller is already a member; no write is needed at join +/// time. `AutoAddRequired` means a membership write is still needed; it is +/// deferred into the same DB transaction that inserts the `48101` event, so +/// neither can commit without the other. +#[derive(Debug, Clone)] +pub(crate) enum MembershipAdmission { + /// Caller is already a member of the audio channel. + Existing { parent_channel_id: Uuid }, + /// Caller is a member of the parent channel and needs auto-add to the + /// audio channel. The write is deferred into `commit_participant_join`. + AutoAddRequired { + parent_channel_id: Uuid, + channel_created_by: Vec, + }, +} + +/// Pre-admission ownership guard for the audio join path. +/// +/// Owns all still-unattached resources acquired before `commit_participant_join` +/// succeeds: the unattached Redis lease (if this pod won the CAS), the remote +/// session + stream (if this is a cross-pod join), and the peer ID once admitted +/// to the local room. Each field is `take`n to `None` only at the single point +/// where it is either committed (transferred into the live runtime) or released +/// (cleaned up on a pre-commit exit). +/// +/// `release_before_commit` releases / closes / removes every field that is still +/// `Some`. It is idempotent: calling it twice has no effect because every field +/// becomes `None` after the first call. After a commit-won, the caller calls +/// `take_*` methods to extract the committed state; any field that was not taken +/// is auto-released when the guard drops (unreachable in normal flow). +/// +/// I1 invariant (transfer-after-commit-won): the `lease` field is held by the +/// guard for the entire pre-commit window. `guard.release_before_commit()` is +/// therefore the single release path for every pre-commit exit — no separate +/// registry call is needed. `take_lease()` is called only at commit-won, and the +/// lease is transferred into `HuddleOwnerRegistry::attach_signals` at that point. +/// +/// This guard satisfies IMPORTANT 1-2 from the pass-3 review: every pre-commit +/// exit uses a single release path so no exit can skip lease release, remote +/// unregister, or peer removal. +struct HuddleAdmissionGuard { + /// Unattached Redis lease won by this connection's CAS, plus the directory + /// needed to release it. `None` when this pod is a steady-state owner + /// (reuses the live registry entry) or a non-owner. Attached into + /// `HuddleOwnerRegistry` only after commit-won. + /// + /// The directory is boxed as `dyn HuddleDirectory` so guard-level tests can + /// inject a `FakeDir` double without requiring a live Redis instance (CW6). + lease: Option<( + crate::audio::join::HuddleLease, + std::sync::Arc, + )>, + /// Remote session registration (owner-assigned index + roster). Set when + /// this pod is a non-owner and `dial_remote_owner` succeeded. + remote_session: Option, + /// Live control stream to the owner pod. Set alongside `remote_session`. + remote_stream: Option, + /// Peer ID in the local room once `add_peer[_at_index]` succeeded. + peer_id: Option, + /// Back-reference to the room for `remove_peer` on pre-commit exit. + room: std::sync::Arc, + /// Back-reference to the room manager for `cleanup_if_empty`. + audio_rooms: std::sync::Arc, + /// Community + channel for `cleanup_if_empty`. + community: buzz_core::CommunityId, + channel_id: Uuid, +} + +impl HuddleAdmissionGuard { + /// Release all still-held resources. Safe to call multiple times; each + /// field becomes `None` on first release. + /// + /// - Unattached lease: calls `directory.release(&lease)` directly and + /// awaits the result before returning ("released before return" is + /// literal — no detached task). Warns on release error. + /// - Remote registration: UnregisterPeer + Goodbye(SessionEnded) on stream. + /// - Peer in room: remove_peer + cleanup_if_empty. + async fn release_before_commit(&mut self) { + // Release the unattached lease by calling directory.release directly. + // This is an awaited call, so "release before return" is guaranteed — + // no detached renewer task that could outlive the caller. + if let Some((lease, directory)) = self.lease.take() { + match directory.release(&lease).await { + Ok(_) => {} + Err(e) => { + tracing::warn!( + "HuddleAdmissionGuard: lease release failed on pre-commit exit: {e}" + ); + } + } + } + // Close the remote registration. + if let (Some(session), Some(ref mut stream)) = + (self.remote_session.as_ref(), self.remote_stream.as_mut()) + { + crate::audio::join::send_clean_close(stream, session.fenced(), session.pubkey()).await; + } + self.remote_session = None; + self.remote_stream = None; + // Remove the peer from the room. + if let Some(pid) = self.peer_id.take() { + self.room.remove_peer(pid); + self.audio_rooms + .cleanup_if_empty(self.community, self.channel_id); } } + + /// Take the remote session (consumed at commit-won for the send-loop task). + fn take_remote_session(&mut self) -> Option { + self.remote_session.take() + } + + /// Take the remote stream (consumed at commit-won for the reader task). + fn take_remote_stream(&mut self) -> Option { + self.remote_stream.take() + } + + /// Take the lease (consumed at commit-won to pass into `attach_signals`). + fn take_lease( + &mut self, + ) -> Option<( + crate::audio::join::HuddleLease, + std::sync::Arc, + )> { + self.lease.take() + } + + /// Take the peer ID (consumed at commit-won so normal teardown owns cleanup). + fn take_peer_id(&mut self) -> Option { + self.peer_id.take() + } } -async fn ensure_membership( +/// Validate membership for audio admission — **no durable write**. +/// +/// Loads the channel, checks archival status, resolves the parent-channel +/// linkage for ephemeral channels, and checks existing membership and parent +/// membership. Returns [`MembershipAdmission`] describing what still needs +/// to happen at commit time. +/// +/// Performs zero DB writes. Any needed auto-add write is deferred into the +/// caller-owned transaction inside `commit_participant_join`. +async fn check_membership_for_admission( state: &AppState, tenant: &TenantContext, channel_id: Uuid, pubkey_bytes: &[u8], parent_channel_id: Option, -) -> Result { +) -> Result { + // Test hook: fires at the entry of the membership check so a test can arm + // expiry between NIP-42 pairing and the first DB read. Proves that a + // cancellation before membership check produces zero DB side effects. + // No-op in production. [nip_fi_test_hooks::audio_membership_check_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_membership_check(tenant.community()).await; + // Load channel first — reject archived channels before any membership check. - // This ensures auto-ended huddles can't be rejoined by existing members. let channel = state .db .get_channel(tenant.community(), channel_id) @@ -1287,8 +2355,6 @@ async fn ensure_membership( } // Lifecycle events for an ephemeral huddle belong in its parent channel. - // Resolve that parent from a creator-signed kind:48100 event instead of - // trusting the UUID supplied by the client during audio auth. let lifecycle_parent_id = if channel.ttl_seconds.is_some() { let parent_id = parent_channel_id.ok_or("ephemeral channel requires parent linkage")?; let linked = state @@ -1316,11 +2382,15 @@ async fn ensure_membership( .map_err(|e| format!("db error: {e}"))?; if is_member { - return Ok(lifecycle_parent_id); + return Ok(MembershipAdmission::Existing { + parent_channel_id: lifecycle_parent_id, + }); } if channel.visibility == "open" { - return Ok(lifecycle_parent_id); + return Ok(MembershipAdmission::Existing { + parent_channel_id: lifecycle_parent_id, + }); } // Auto-add path: private ephemeral channel + caller is member of parent. @@ -1331,24 +2401,365 @@ async fn ensure_membership( .map_err(|e| format!("db error: {e}"))?; if parent_member { + return Ok(MembershipAdmission::AutoAddRequired { + parent_channel_id: lifecycle_parent_id, + channel_created_by: channel.created_by.clone(), + }); + } + } + + Err("not a member".into()) +} + +/// Outcome returned by [`commit_participant_join`] on the `Ok` path. +/// +/// Indicates whether the `joined` broadcast was queued inside the permit +/// (always the case with `broadcast_control`) or whether the peer's ctrl +/// channel was saturated and the message was dropped (the forward loop will +/// detect the dead channel and drive normal admitted teardown from there). +#[derive(Debug)] +pub(crate) enum CommitJoinOutcome { + /// `joined` was queued to all peers' ctrl channels inside the permit. + JoinedSent, + /// The joining peer's ctrl channel was already saturated; the message + /// was dropped. The forward loop will close via the dead channel. + /// Structurally unreachable at this time (fresh peer channel is never + /// full), kept as a safety valve for future capacity changes. + #[allow(dead_code)] + JoinedSendFailed, +} + +/// Error returned by [`commit_participant_join`]. +#[derive(Debug)] +pub(crate) enum JoinCommitError { + /// DB transaction setup or commit failed. + Db(buzz_db::DbError), + /// The session gate rejected the permit (session expired before commit). + Expired, + /// Channel was archived between pre-join check and commit (IMPORTANT 4). + Archived, + /// Parent membership was revoked between pre-join check and commit (IMPORTANT 4). + ParentMembershipLost, + /// Creator-signed huddle_started link was deleted between pre-join check + /// and commit (IMPORTANT 4 residual: third carried fact). + HuddleLinkGone, +} + +impl std::fmt::Display for JoinCommitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JoinCommitError::Db(e) => write!(f, "db error: {e}"), + JoinCommitError::Expired => write!(f, "session expired before commit"), + JoinCommitError::Archived => write!(f, "channel archived before commit"), + JoinCommitError::ParentMembershipLost => { + write!(f, "parent membership revoked before commit") + } + JoinCommitError::HuddleLinkGone => { + write!(f, "huddle_started creator link gone before commit") + } + } + } +} + +impl From for JoinCommitError { + fn from(e: buzz_db::DbError) -> Self { + JoinCommitError::Db(e) + } +} + +/// Atomically commit the participant join: auto-add membership (if needed) + +/// kind `48101` event, in one DB transaction, under a session effect permit. +/// +/// Ordering (per B1 contract [e5bc0382], corrected for IMPORTANT 4 and 5): +/// 1. Sign the `48101` event synchronously. +/// 2. Begin a caller-owned DB transaction. +/// 3. Under the channel membership lock (AutoAddRequired only): +/// a. Re-read channel archive state — fail `Archived` if now archived. +/// (IMPORTANT 4: closes the race between pre-join check and commit.) +/// b. Re-read parent membership — fail `ParentMembershipLost` if gone. +/// c. Re-read creator-signed huddle_started link — fail `HuddleLinkGone` +/// if the link was deleted between pre-join check and commit. +/// (IMPORTANT 4 residual: third carried fact, alongside archive + parent.) +/// d. Re-read child membership — skip auto-add insert if a concurrent +/// legitimate add is already present (concurrent-add preservation). +/// 4. Insert kind `48101` in the same transaction (uncommitted). +/// 5. Acquire a session effect permit (or rollback + return `Err(Expired)`). +/// 6. Commit the transaction while holding the permit. +/// 7. While the same permit is held: mark the event locally, fan out to local +/// subscribers, publish to Redis, and broadcast `joined` to all peers +/// (including the joiner) via `room.broadcast_control`. (IMPORTANT 5: +/// `joined` publication inside the commit-won permit.) Drop permit after. +/// +/// Never cancels or drops the commit future once started — commit returns a +/// known outcome and that outcome drives success or the pre-admission cleanup. +/// +/// Argument count reflects the join's natural surface; a param struct would +/// obscure more than it clarifies at this single call site. +#[allow(clippy::too_many_arguments)] +async fn commit_participant_join( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + parent_channel_id: Uuid, + pubkey_hex: &str, + pubkey_bytes: &[u8], + peer_id: Uuid, + roster_revision: u64, + lifecycle_generation: &str, + membership_admission: &MembershipAdmission, + gate: &std::sync::Arc, + joined_msg: String, + room: &std::sync::Arc, +) -> Result { + // 1. Sign the 48101 event synchronously. + // Include `generation` (matching the wire key Desktop's `lifecycleContent` parser + // reads at `huddlePresence.ts:83-85`) so Desktop reconciliation can compare + // against liveness responses — without it, an already-hydrated observer sees + // a JOIN without a generation, treats it as "pending", and then clears + // admissions when the next authoritative liveness response (with a real + // generation) differs. [F3: generation key in 48101 JOIN — Carl review] + let content = serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string(), + "roster_revision": roster_revision, + "admission_id": peer_id.to_string(), + "generation": lifecycle_generation, + }) + .to_string(); + + let h_tag = Tag::parse(["h", &parent_channel_id.to_string()]).map_err(|e| { + JoinCommitError::Db(buzz_db::DbError::InvalidData(format!( + "failed to build h tag: {e}" + ))) + })?; + let p_tag = Tag::parse(["p", pubkey_hex]).map_err(|e| { + JoinCommitError::Db(buzz_db::DbError::InvalidData(format!( + "failed to build p tag: {e}" + ))) + })?; + let event = EventBuilder::new(Kind::Custom(48101), content) + .tags(vec![h_tag, p_tag]) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| { + JoinCommitError::Db(buzz_db::DbError::InvalidData(format!( + "failed to sign 48101: {e}" + ))) + })?; + let event_id_hex = event.id.to_hex(); + + // 2. Begin a caller-owned DB transaction. + let mut tx = state.db.begin_event_write_transaction().await?; + + // 3. Under the channel membership lock: re-validate authority + auto-add if + // still absent. The AutoAddRequired path carries stale authority from + // check_membership_for_admission; the lock serialises all membership writes + // for this channel so the re-reads observe the most recent committed state. + if let MembershipAdmission::AutoAddRequired { + parent_channel_id: parent_id, + channel_created_by, + } = membership_admission + { + // Test hook: fires immediately before the channel membership lock is + // acquired. A test can insert a membership row externally here to prove + // the concurrent-add case is handled (re-read observes it → still_absent + // = false → auto-add insert is skipped → membership preserved). + // [nip_fi_test_hooks::audio_membership_lock_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_membership_lock(tenant.community()).await; + + buzz_db::channel_members::acquire_channel_membership_lock_in_transaction( + &mut tx, + tenant.community(), + channel_id, + ) + .await?; + + // IMPORTANT 4a: Re-read channel archive state under the lock. A channel + // could be archived in the window between check_membership_for_admission + // and now; committing a join into an archived channel violates the + // "no admission after archive" invariant. + let channel_archived: Option> = sqlx::query_scalar( + "SELECT archived_at FROM channels \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .fetch_optional(tx.as_mut()) + .await + .map_err(buzz_db::DbError::from)? + .flatten(); + + if channel_archived.is_some() { + let _ = tx.rollback().await; + return Err(JoinCommitError::Archived); + } + + // IMPORTANT 4b: Re-read parent membership under the lock. A parent + // membership revocation in the same window would make the auto-add + // unjustified; reject rather than grant access from stale authority. + let parent_still_member = buzz_db::channel_members::is_member_in_transaction( + &mut tx, + tenant.community(), + *parent_id, + pubkey_bytes, + ) + .await?; + + if !parent_still_member { + let _ = tx.rollback().await; + return Err(JoinCommitError::ParentMembershipLost); + } + + // IMPORTANT 4 residual: Re-read the creator-signed huddle_started link + // inside the transaction. This is the third carried fact alongside the + // archive + parent-membership re-reads. The link could be deleted by a + // concurrent channel teardown after check_membership_for_admission ran + // but before this transaction acquires the lock; committing a join into + // an unlinked channel violates the "creator authority" invariant. + let link_still_exists = buzz_db::event::huddle_started_link_exists_in_transaction( + &mut tx, + tenant.community(), + *parent_id, + channel_id, + channel_created_by.as_slice(), + ) + .await?; + + if !link_still_exists { + let _ = tx.rollback().await; + return Err(JoinCommitError::HuddleLinkGone); + } + + // Re-read child membership — a concurrent legitimate add may have + // already provided access; do not overwrite role/provenance. + let still_absent = !buzz_db::channel_members::is_member_in_transaction( + &mut tx, + tenant.community(), + channel_id, + pubkey_bytes, + ) + .await?; + + if still_absent { + buzz_db::channel_members::insert_auto_membership_in_transaction( + &mut tx, + tenant.community(), + channel_id, + pubkey_bytes, + channel_created_by.as_slice(), + ) + .await?; + } + // If not still_absent: concurrent add observed — membership preserved. + } + + // 4. Insert kind `48101` uncommitted. + let (stored, was_inserted) = buzz_db::event::insert_event_in_transaction( + &mut tx, + tenant.community(), + &event, + Some(parent_channel_id), + ) + .await?; + + // 5. Acquire effect permit or rollback. + // + // Test hook: fires between the uncommitted 48101 insert and the permit + // acquisition. A test can arm expiry here to prove that a cancellation + // after the DB write but before commit rolls back the transaction and + // produces zero committed side effects. + // [nip_fi_test_hooks::audio_participant_commit_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_participant_commit(tenant.community()).await; + let _permit = match gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + // Rollback explicitly — no 48101 or membership write committed. + let _ = tx.rollback().await; + return Err(JoinCommitError::Expired); + } + }; + + // 6. Commit while holding the permit. + if let Err(e) = tx.commit().await { + return Err(JoinCommitError::Db(e.into())); + } + + // 7. Fan-out while permit is still held — expiry cannot complete between + // row visibility and fan-out. + if was_inserted { + state.mark_local_event(tenant.community(), &event.id); + crate::handlers::event::fan_out_event_to_local_subscribers( + state, + tenant.community(), + &stored, + ) + .await; + + if let Err(e) = state + .pubsub + .publish_event(tenant, EventTopic::Channel(parent_channel_id), &event) + .await + { state - .db - .add_member( - tenant.community(), - channel_id, - pubkey_bytes, - MemberRole::Member, - Some(&channel.created_by), - ) - .await - .map_err(|e| format!("auto-add failed: {e}"))?; - state.invalidate_membership(tenant, channel_id, pubkey_bytes); + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!( + event_id = %event_id_hex, + channel_id = %parent_channel_id, + "audio: failed to publish 48101: {e}" + ); + } - return Ok(lifecycle_parent_id); + // Best-effort mention insertion — outside the gate, failure is a warn. + if let Err(e) = buzz_db::insert_mentions( + state.db.pool(), + tenant.community(), + &event, + Some(parent_channel_id), + ) + .await + { + warn!(event_id = %event_id_hex, "audio: failed to insert 48101 mentions: {e}"); } + } else { + debug!( + event_id = %event_id_hex, + channel_id = %parent_channel_id, + "audio: 48101 already persisted — skipping fan-out" + ); } - Err("not a member".into()) + // IMPORTANT 5: broadcast `joined` to all peers (including the joiner) while + // the commit-won permit is still held. `broadcast_control` sends via each + // peer's ctrl channel; the joining peer's channel was created by add_peer and + // is read by the audio_forward_loop once it starts. The message is buffered + // in that channel until the loop drains it. + // + // The peer's ctrl channel is freshly created by add_peer (capacity 8) so the + // try_send inside broadcast_control will succeed. JoinedSent is always + // returned; JoinedSendFailed is structurally unreachable here but kept for + // completeness — the forward loop's dead-channel path handles any future + // saturation case at runtime. + room.broadcast_control(joined_msg); + let outcome = CommitJoinOutcome::JoinedSent; + + // Test hook: fires after fan-out and `joined` broadcast, but BEFORE + // `_permit` drops. Used by CW10: expiry armed here blocks at the write + // guard until the permit drops at the end of this scope. + // [nip_fi_test_hooks::audio_participant_fanout_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::after_participant_fanout(tenant.community()).await; + // _permit drops here — gate quiescence barrier may proceed. + + // After commit, invalidate the membership cache if we auto-added. + if matches!( + membership_admission, + MembershipAdmission::AutoAddRequired { .. } + ) { + state.invalidate_membership(tenant, channel_id, pubkey_bytes); + } + + Ok(outcome) } #[derive(Clone, Copy)] @@ -1495,6 +2906,7 @@ mod tests { use axum::{routing::get, Router}; use futures_util::SinkExt; + use sqlx::Row as _; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -1584,6 +2996,8 @@ mod tests { .expect("queue state-bearing control"); let task_cancel = CancellationToken::new(); let connection_cancel = CancellationToken::new(); + let connection_control = + crate::state::CommunityConnectionControl::new(connection_cancel.clone()); audio_forward_loop( audio_rx, @@ -1591,7 +3005,7 @@ mod tests { data_tx, ctrl_tx, task_cancel, - connection_cancel.clone(), + connection_control, ) .await; @@ -1609,6 +3023,8 @@ mod tests { let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); let task_cancel = CancellationToken::new(); let connection_cancel = CancellationToken::new(); + let connection_control = + crate::state::CommunityConnectionControl::new(connection_cancel.clone()); let forward = tokio::spawn(audio_forward_loop( audio_rx, @@ -1616,7 +3032,7 @@ mod tests { data_tx, ctrl_tx, task_cancel, - connection_cancel.clone(), + connection_control, )); drop(peer_ctrl_tx); @@ -1685,7 +3101,15 @@ mod tests { messages: Arc::clone(&messages), }; - send_loop(sink, data_rx, ctrl_rx, cancel, disconnect_reason).await; + send_loop( + sink, + data_rx, + ctrl_rx, + mpsc::channel(1).1, + cancel, + disconnect_reason, + ) + .await; let messages = messages.lock().expect("mock sink poisoned"); assert_eq!(messages.len(), 1); @@ -1709,4 +3133,6497 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + // ── Witness B: Audio pairing mismatch through the real audio path ───────── + // + // Drives the production `handle_active_audio_connection` over a real local + // WebSocket pair. Key A is named in the assertion; key B signs the audio + // auth message — mismatch. The function must deliver the exact restricted + // JSON frame and cancel before returning. + // + // The test calls `handle_active_audio_connection` directly (bypassing + // `handle_audio_connection`/`run_registered_community_connection`) so no + // live DB connection is required: the pairing fires before any membership + // DB gate, so a lazy pool suffices. + // + // Mutation evidence: + // - Delete the production call from `handle_active_audio_connection` → + // exact restricted frame absent (or a later, different error arrives); + // test panics on frame content or cancellation assertion. + // - Delete the denial branch inside `enforce_nip_fi_key_pairing` → same. + // - Change the JSON shape/text → byte assertion panics. + // - Omit cancellation → cancellation assertion panics. + + async fn audio_test_state() -> std::sync::Arc { + use std::sync::Arc; + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn handle_active_audio_connection_pairing_mismatch_runs_full_audio_denial_path() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key_a = nostr::Keys::generate(); + let key_b = nostr::Keys::generate(); + + let assertion = VerifiedAssertion::for_test( + Some(key_a.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let state = audio_test_state().await; + let _channel_id = uuid::Uuid::new_v4(); + + // Build a real tenant context matching what `nip42_expected_relay_url` + // will compute (scheme from config.relay_url = "ws://", host = "test.local"). + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ); + + // Set up a local WS server that runs `handle_active_audio_connection`. + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + // conn_cancel is created here so the test retains it for the + // is_cancelled() assertion. The token is cloned into the server closure. + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + // Clone once for the closure; the original is retained + // outside for the cancellation assertion. + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + // Wait for server to be ready, then get the cancel token it sent. + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + // Refactor: the server uses its own cancel per connection (above). + // We instead track completion by the WS close message. + + // Connect the client. + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Receive the challenge message. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + // Sign the auth message with key B (mismatch — assertion names key A). + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key_b) + .unwrap(); + + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // The server must send the exact restricted JSON frame before closing. + let frame = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("restricted frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "audio pairing mismatch must produce exact restricted JSON before close" + ); + } + other => panic!("expected Text(restricted JSON); got {other:?}"), + } + + // The connection must close after the denial. The audio path sends the + // restricted frame directly on ws_send, then drops it (no send_loop to + // drain a Close frame). The client may see either: + // a) a WS Close frame if axum's runtime sends one on drop, or + // b) None / Err (connection reset) when the socket drops. + // Both are acceptable — the key check is that the restricted frame was + // already received above. + let close = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("close timeout"); + assert!( + matches!( + close, + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | Some(Err(_)) | None + ), + "connection must close after audio pairing mismatch; got {close:?}" + ); + + // The retained token must be cancelled — this is the named mutation + // target: omit cancel.cancel() inside enforce_nip_fi_key_pairing and + // this assertion fails even though the socket still drops. + assert!( + cancel_for_assert.is_cancelled(), + "conn_cancel must be cancelled after audio pairing mismatch" + ); + + server.abort(); + let _ = server.await; + } + + // ── W5 (B1 audio): already-expired deadline rejects at pairing, before admission + // + // When the NIP-FI session deadline is already past at pairing time (the + // assertion's authority deadlines are all in the past), `handle_active_audio_connection` + // must send the canonical `restricted` denial frame and close the connection + // before writing any relay-membership, room-join, or roster side effect. + // + // This test gives the handler the same key in both the assertion and the + // NIP-42 event so pairing succeeds, but sets an already-expired deadline. + // The B1 gate fires between the pairing check and `enforce_relay_membership`. + // + // Mutation evidence: + // A) Delete the B1 already-expired check → the B1 restricted frame is + // not sent before admission; the membership gate fires next. Since the + // test's lazy DB rejects membership, the frame text changes from + // "restricted: authorization denied" to "restricted: not a relay member" + // → the byte assertion panics. + // B) Change the sent frame text → byte assertion panics. + // C) Omit `cancel.cancel()` in the B1 branch → cancel assertion panics. + + #[tokio::test] + async fn b1_already_expired_session_denied_at_pairing_before_admission() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + + // Assertion: same key for both assertion and NIP-42 event → pairing passes. + // But the deadline is 2 seconds in the past → B1 fires. + let expired_deadline = Utc::now() - Duration::seconds(2); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![expired_deadline]); + + let state = audio_test_state().await; + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Receive the challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + // Sign the auth message with the SAME key as the assertion — pairing passes. + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // The B1 gate must send the exact canonical restricted JSON frame. + // This is byte-identical to the pairing-mismatch frame — same production + // `authorization_denied_frame(NipFiWsRoute::Audio)` path. + let frame = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("restricted frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "B1: expired session must produce exact canonical restricted JSON before close" + ); + } + other => panic!("B1: expected Text(restricted JSON); got {other:?}"), + } + + // Connection must close after the B1 denial. + let close = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("close timeout"); + assert!( + matches!( + close, + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | Some(Err(_)) | None + ), + "B1: connection must close after expired-session denial; got {close:?}" + ); + + // The cancel token must be cancelled — omitting cancel.cancel() in the + // B1 branch makes this assertion fail even when the socket still drops. + assert!( + cancel_for_assert.is_cancelled(), + "B1: conn_cancel must be cancelled after expired-session denial at pairing" + ); + + server.abort(); + let _ = server.await; + } + + // ── W6 (B1 audio mid-admission): cancellation before room.add_peer ───────── + // + // With the expiry task armed before admission (above the first persisting + // step), a cancellation fired during the admission sequence must prevent + // room.add_peer from executing. The audio room must remain empty. + // + // This test fires the expiry task between the pairing check and the first + // check_cancel!() boundary. To avoid a sleep-lottery it uses the connection + // cancel token directly: the token is pre-cancelled, which is equivalent to + // the expiry task firing before check_cancel!() is reached. The room is + // inspected after the handler returns to confirm no peer was added. + // + // The biased auth-loop select fires `cancel.cancelled()` → return before + // reaching check_cancel!(). The room invariant (no peer added) is the + // observable outcome that must hold regardless of which cancellation path + // fires. The mutation evidence for the check_cancel!() fences themselves is + // in the focused unit tests in connection.rs (B2/B3 tests), where the fence + // mechanism is exercised in isolation. + // + // What this test proves end-to-end: + // A real audio connection with a cancelled token cannot reach room.add_peer. + // This was NOT true before the B1 fix: the expiry task was armed AFTER + // room.add_peer (line ~858), so it could not prevent admission. + // + // Mutation evidence: + // A) Move the expiry task creation back to after room.add_peer (the pre-fix + // location) → test still passes (cancel path fires first). The test is + // therefore evidence of the cancel-stops-admission invariant, not of the + // exact placement of the expiry arm. + // B) Remove `_ = cancel.cancelled() => return` from the audio auth select → + // handler proceeds to auth exchange → if auth takes > 3 s (timeout) the + // test fails; in practice the close assertion fires immediately. + + #[tokio::test] + async fn b1_mid_admission_expiry_does_not_add_peer_to_room() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let key = nostr::Keys::generate(); + // A non-expired assertion — pairing passes if we reach that check. + // The cancellation intercepts before pairing, so the room stays empty. + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let state = audio_test_state().await; + let audio_rooms = Arc::clone(&state.audio_rooms); + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ); + let channel_id = uuid::Uuid::new_v4(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + // Pre-cancel: token is set before handle_active_audio_connection runs. + // The biased `_ = cancel.cancelled() => return` in the audio auth select + // fires at the first executor poll, preventing any room mutation. + let conn_cancel = CancellationToken::new(); + conn_cancel.cancel(); + let cancel_clone = conn_cancel.clone(); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = cancel_clone.clone(); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Server sends the challenge then exits immediately (biased cancel fires). + // The client receives the challenge, then observes the connection close. + let _challenge = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .ok(); // May succeed (challenge) or fail (connection already dropped). + + // The connection must close before the 3 s timeout. + let close = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()).await; + assert!( + close.is_ok(), + "B1: connection must close before timeout when token is pre-cancelled" + ); + + // The audio room must be empty — no peer was added. + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()); + if let Some(room) = audio_rooms.get(community, channel_id) { + assert!( + room.is_empty(), + "B1: audio room must have zero peers when cancel fires before room.add_peer" + ); + } + // Room may not exist at all — that also satisfies the invariant. + + server.abort(); + let _ = server.await; + } + + // ── W7 (B3 audio): audio expiry sends exact restricted frame before close ──── + // + // Drives BOTH production seams: + // 1. `nip_fi_session::spawn_nip_fi_expiry_task` with `NipFiWsRoute::Audio`. + // 2. The real generic audio `send_loop` with a recording sink. + // + // The expiry constructor synchronously queues the denial on `ctrl_tx` and + // cancels without any await in between, so the audio send loop's + // cancellation drain picks up the frame before writing Close. + // + // Mutation evidence: + // - Delete/change the audio enqueue in `spawn_nip_fi_expiry_task` → + // output lacks or mismatches frame 0. + // - Revert the audio send_loop cancellation drain → output begins with + // Close(None) or lacks the restricted frame entirely. + // - Replace audio's production constructor call with a copied local task → + // structural requirement: exactly one `spawn_nip_fi_expiry_task` + // definition (in `nip_fi_session`) and two production invocations (root + // in `connection.rs`, audio in `audio/handler.rs`). Any copy breaks + // this test's coupling to the shared producer. + + #[tokio::test] + async fn audio_expiry_sends_exact_restricted_frame_before_close() { + use std::pin::Pin; + use std::sync::Arc; + use std::task::{Context, Poll}; + use tokio::sync::mpsc; + + // Recording sink that stores every message in order. + struct RecordSink(Arc>>); + impl futures_util::Sink for RecordSink { + type Error = std::convert::Infallible; + fn poll_ready( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.get_mut() + .0 + .try_lock() + .expect("RecordSink lock") + .push(item); + Ok(()) + } + fn poll_flush( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn poll_close( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + self.poll_flush(cx) + } + } + + let recorded = Arc::new(tokio::sync::Mutex::new(Vec::::new())); + let sink = RecordSink(Arc::clone(&recorded)); + + let (_data_tx, data_rx) = mpsc::channel::(4); + let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + let (terminal_tx, terminal_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + // Share the control's reason watch with the send_loop so the expiry + // task's AuthorizationDenied write is observed as the 1008 close code. + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let disconnect_rx = control.disconnect_reason(); + + // Step 1: spawn audio send_loop and yield so it parks in its select. + let send_cancel = cancel.clone(); + let send_handle = tokio::spawn(send_loop( + sink, + data_rx, + ctrl_rx, + terminal_rx, + send_cancel, + disconnect_rx, + )); + tokio::task::yield_now().await; + + // Step 2: invoke the shared expiry constructor with an already-expired + // deadline. Queue-then-cancel is synchronous: the send loop's cancellation + // branch drains the terminal frame before writing Close. + let already_expired = chrono::Utc::now() - chrono::Duration::seconds(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(already_expired, cancel.clone()); + let expiry_handle = crate::nip_fi_session::spawn_nip_fi_expiry_task( + already_expired, + gate, + terminal_tx, + crate::nip_fi_session::NipFiWsRoute::Audio, + control, + ); + expiry_handle.await.expect("expiry task must complete"); + drop(ctrl_tx); // satisfy the unused-variable lint + + // Step 3: await the writer and assert exact two-frame sequence. + tokio::time::timeout(std::time::Duration::from_secs(2), send_handle) + .await + .expect("send_loop must complete within timeout") + .expect("send_loop task must not panic"); + + let frames = recorded.lock().await; + assert_eq!( + frames.len(), + 2, + "expected exactly 2 frames (restricted JSON, then Close); got {:?}", + *frames + ); + + // Frame 0: exact canonical restricted JSON. + let expected = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frames[0] { + WsMessage::Text(t) => assert_eq!( + t.as_str(), + expected.as_str(), + "frame 0 must be exact canonical restricted JSON" + ), + other => panic!("frame 0 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 1: Close(Some) with 1008 POLICY code — expiry sets AuthorizationDenied + // on the disconnect_reason watch so the send loop emits a policy close. + match &frames[1] { + WsMessage::Close(Some(cf)) => { + assert_eq!( + cf.code, + axum::extract::ws::close_code::POLICY, + "frame 1 must be 1008 POLICY close; got code {}", + cf.code + ); + assert_eq!( + cf.reason.as_str(), + "authorization denied", + "frame 1 close reason must be 'authorization denied'" + ); + } + other => { + panic!("frame 1 must be Close(Some(POLICY, 'authorization denied')); got {other:?}") + } + } + } + + // ── W_FIX1: check_cancel!() pre-send-loop path emits restricted JSON then 1008 ── + // + // Drives the REAL `handle_active_audio_connection` through a held admission + // boundary so the ACTUAL `check_cancel!()` macro arm (audio/handler.rs:474-488) + // executes and client-observable frames are asserted. + // + // Fix 1 added drain-then-close to every `check_cancel!()` arm and the four + // manual expiry exits. Before the fix, `check_cancel!` drained the terminal + // channel (denial frame) but returned without a close frame — clients observed + // 1005/1006. After the fix the arm also sends `reason.close_message()` when + // `disconnect_reason` is `AuthorizationDenied`. + // + // Setup: + // - Key is NOT in the deny map (passes post-registration deny check). + // - Assertion carries a 1 s NIP-FI deadline → expiry task spawned at that + // deadline, `terminal_ctrl_tx` wired internally. + // - `before_first_audio_check_cancel` hook holds the handler AFTER the expiry + // task is spawned but BEFORE the first `check_cancel!()` invocation. + // - Expiry task fires naturally (1 s deadline passes while the hook holds). + // It queues the denial frame on the internal `terminal_ctrl_tx`, publishes + // `AuthorizationDenied` on the real `disconnect_reason` watch, and cancels. + // - Hook is released only after the token is confirmed cancelled, guaranteeing + // the expiry task has committed its effects before the handler resumes. + // - Handler resumes, hits `check_cancel!()` at the real production call site, + // drains the denial frame, sends `reason.close_message()`. + // + // Mutation evidence (production seam, not copies): + // A) Remove the `if let Some(reason) = nip_fi_close_reason` block from the + // plain `check_cancel!()` arm (handler.rs:483-486) → client receives only + // the restricted JSON frame, no close → close assertion times out → panics. + // B) Replace `reason.close_message()` in that arm with `WsMessage::Close(None)` + // → `Close(None)` from client → POLICY code assertion panics. + // C) Move `drain` AFTER `close_message()` in that arm → close arrives before + // the restricted JSON frame → `frame[0] is Text` assertion panics. + // D) Delete `while let Ok(msg) = terminal_ctrl_rx.try_recv()` drain from that + // arm → no restricted JSON frame → client sees only the close → panics. + // NOTE: mutations A–D target handler.rs:474-488 (the no-arg `check_cancel!` + // arm). Deleting those blocks leaves this test red; W7 independently witnesses + // the post-send-loop `send_loop` cancel branch. + + #[tokio::test] + async fn pre_send_loop_check_cancel_emits_restricted_json_then_policy_close() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + // Key absent from deny map → passes deny-set check. + let key = nostr::Keys::generate(); + // 1 s deadline: long enough that, under a loaded parallel unit run, + // pairing never lands after it (which would take the B1 "already expired + // at pairing" exit instead of this seam), short enough that the expiry + // task fires inside the 2 s cancel wait while the hook holds. + let deadline = Utc::now() + Duration::seconds(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // State with deny map (issuer "test-issuer") but key not denied. + let state = audio_deny_state(None).await; + + // Unique community so hook slot does not collide with parallel tests. + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + + // CommunityConnectionControl is Clone: create one for the server closure. + let control_for_server = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("W_FIX1: bind test listener"); + let addr = listener.local_addr().expect("W_FIX1: test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_i = control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_i = control_i.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_i, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("W_FIX1: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("W_FIX1: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("W_FIX1: connect client"); + + // Arm `audio_before_first_check_cancel_hook` BEFORE auth so the handler + // is captured at the exact `check_cancel!()` seam — after the expiry task + // has been spawned (line ~426) but before the first cancel check fires. + let (hook_arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_before_first_check_cancel_hook::arm(community); + + // NIP-42 challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_FIX1: challenge timeout") + .expect("W_FIX1: challenge item") + .expect("W_FIX1: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("W_FIX1: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("W_FIX1: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("W_FIX1: challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("W_FIX1: send auth"); + + // Wait for handler to reach before_first_audio_check_cancel. + // At this point the expiry task has already been spawned with a 1 s + // deadline; we hold here while it fires. + tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) + .await + .expect("W_FIX1: handler must reach before_first_audio_check_cancel within 5s") + .expect("W_FIX1: hook arrived channel closed"); + + // Handler is now held. The expiry task was spawned with a 1 s deadline; + // wait for it to fire (cancel token is set when it does). It will queue the + // denial frame on the internal terminal channel, publish AuthorizationDenied + // on the disconnect_reason watch, then cancel. + let cancel_wait = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if cancel_for_assert.is_cancelled() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }); + cancel_wait + .await + .expect("W_FIX1: expiry task must fire and set cancel within 2s"); + + // Release the hook. Handler resumes, hits check_cancel!() immediately, + // drains terminal_ctrl_rx (denial frame) then sends reason.close_message(). + hook_release.notify_one(); + + // ── Client-observed frame assertions ────────────────────────────────── + // + // Frame 0: restricted JSON denial payload queued by the expiry task. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("W_FIX1: frame 0 timeout") + .expect("W_FIX1: frame 0 item") + .expect("W_FIX1: frame 0 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "W_FIX1: frame 0 must be exact canonical restricted JSON" + ), + other => panic!("W_FIX1: frame 0 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 1: 1008 POLICY close emitted by check_cancel!()'s close_message() call. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_FIX1: frame 1 timeout") + .expect("W_FIX1: frame 1 item") + .expect("W_FIX1: frame 1 message"); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "W_FIX1: close code must be 1008 POLICY; got {:?}", + cf.code + ); + assert_eq!( + cf.reason.as_str(), + "authorization denied", + "W_FIX1: close reason must be 'authorization denied'" + ); + } + other => { + panic!( + "W_FIX1: frame 1 must be Close(Some(1008, 'authorization denied')); got {other:?}" + ) + } + } + + server.abort(); + let _ = server.await; + } + + // ── W8: barrier at membership check — cancel before first DB read ───────── + // + // Arms `before_membership_check` — the hook at the very start of + // `check_membership_for_admission`, before any DB read. Calls the function + // directly in a spawned task with a live gate. When the hook signals arrival, + // fires cancel (simulates expiry). Releases the hook. The function then + // attempts its first DB read (which fails with a lazy-pool error) and + // returns Err. This proves the hook fires before any DB call. + // + // Observable invariant: cancel is set before the function returns, and the + // function returns without writing any membership row. + // + // Hook location: entry of `check_membership_for_admission`, before the first + // `state.db.get_channel()` call. + // + // Mutation evidence: + // A) Delete `before_membership_check(...)` from check_membership_for_admission → + // hook never fires → `arrived_rx` times out → test panics. + // B) Move the hook after `state.db.get_channel()` → hook fires after DB read + // (order changed); on a lazy pool the DB read errors out before the hook + // → arrived_rx times out → test panics. + // C) Supply a real DB where get_channel returns an archived channel → + // function returns "channel is archived" before the hook (but after the + // first DB call) → hook never fires → arrived_rx times out → test panics. + // (This variant is tested in the DB integration suite.) + #[tokio::test] + async fn w8_membership_check_barrier_fires_before_db_read() { + use buzz_core::tenant::{CommunityId, TenantContext}; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let state = audio_test_state().await; + // Fresh community — the hook maps are process-global, so a shared key + // would let any concurrent test that drives the handler through + // before_membership_check consume this gate in the fallback's single + // libtest process. + let community = CommunityId::from_uuid(Uuid::new_v4()); + let tenant = TenantContext::resolved(community, "test.local".to_string()); + let channel_id = Uuid::new_v4(); + let pubkey = nostr::Keys::generate().public_key(); + let pubkey_bytes = pubkey.to_bytes().to_vec(); + + let cancel = CancellationToken::new(); + + // Arm the hook at the entry of check_membership_for_admission. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_membership_check_hook::arm(community); + + let state2 = std::sync::Arc::clone(&state); + let tenant2 = tenant.clone(); + let cancel2 = cancel.clone(); + let handle = tokio::spawn(async move { + super::check_membership_for_admission( + &state2, + &tenant2, + channel_id, + &pubkey_bytes, + None, + ) + .await + }); + + // Wait for the function to reach the hook (before any DB call). + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W8: check_membership_for_admission must reach hook within 5s") + .expect("arrived channel closed"); + + // Cancel — simulates expiry firing before the first DB read. + cancel2.cancel(); + + // Release — function resumes and attempts its first DB read. + release.notify_one(); + + // Wait for the function to complete (DB error on lazy pool, or real result). + // Note: with a lazy pool at port 1, the DB call may hang indefinitely + // (sqlx pool acquisition blocks waiting for a connection). We abort the + // task rather than waiting — the key invariants are already established: + // the hook fired (arrived_rx succeeded above) and cancel is set. + let _ = tokio::time::timeout(std::time::Duration::from_millis(200), handle).await; + + // Cancel was set before the function's first DB call. + assert!(cancel.is_cancelled(), "W8: cancel must be set"); + + // The hook fired at the entry of check_membership_for_admission — before + // any DB call. `arrived_rx` succeeded above proves this invariant. + // The function returned before any membership row was written (it only reads + // in check_membership_for_admission — all writes go to commit_participant_join). + // Whether the DB call errored (fast refusal) or is still pending (slow pool) + // is irrelevant — the hook-fired invariant is what W8 establishes. + let _ = cancel2; // suppress unused warning + } + + // ── W_audio_deny: audio post-registration deny-set check (active, absent, straddle) + // + // Three witnesses prove the S4 normative deny-set check in + // `handle_active_audio_connection` is correctly placed AFTER registration. + // + // All three use the same real-WS-server pattern as W5/W6. + // The state fixture: + // - `audio_test_state` (lazy DB, port 1) — sufficient because the deny check + // fires BEFORE `enforce_relay_membership` (which is where lazy-DB errors). + // - NipFiDenyMap wired with issuer "test-issuer" (from VerifiedAssertion::for_test). + // + // W_audio_deny_active: key IS in deny map → denied at post-registration check. + // Mutation evidence: + // A) Delete the `is_denied` check from audio/handler.rs → no denial frame; + // instead `enforce_relay_membership` fires → frame text changes to + // "restricted: not a relay member" → byte assertion panics. + // B) Move the deny check to before `audio_post_auth_register` → fires before + // registration; but for a pre-seeded key the test still passes — the + // distinction is in W_audio_deny_straddle. + // C) Remove `cancel.cancel()` from the deny branch → cancel assertion panics. + // + // W_audio_deny_absent: key NOT in deny map → passes deny check → membership + // error (lazy DB). Proves the deny check doesn't fire for innocent keys. + // Mutation evidence: + // A) Invert the `is_denied` condition (deny all keys) → absent key gets the + // `authorization_denied` frame → frame text assertion panics. + // B) Remove the `Some(deny_map)` guard → `nip_fi_deny_map` is None → both + // paths are equivalent → absent test passes regardless; but active test + // would fail (check never fires). + // + // W_audio_deny_straddle: entry inserted in window between registration and check. + // Mutation evidence: + // A) Delete `before_deny_set_check(...)` → hook never fires → + // deny entry inserted AFTER check runs and missed → membership error + // frame received instead of denial → frame text assertion panics. + // B) Remove `is_denied` check entirely → same as (A). + + /// Build a test AppState with a NipFiDenyMap wired for issuer "test-issuer". + /// If `denied_key` is Some, inserts a live deny entry for that key. + /// Uses a lazy DB (port 1) — sufficient because the deny check fires before + /// any DB read in `handle_active_audio_connection`. + async fn audio_deny_state( + denied_key: Option<&nostr::PublicKey>, + ) -> std::sync::Arc { + use std::sync::Arc; + let mut state = (*audio_test_state().await).clone(); + + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + + if let Some(key) = denied_key { + let until = chrono::Utc::now() + chrono::Duration::seconds(3600); + let result = + deny_map.merge_cross_pod_deny("test-issuer", key, until, chrono::Utc::now()); + assert!( + matches!(result, buzz_auth::CrossPodMergeResult::Merged), + "audio_deny_state: deny entry must be inserted for test setup" + ); + } + + state.nip_fi_deny_map = Some(deny_map); + Arc::new(state) + } + + #[tokio::test] + async fn w_audio_deny_active_key_refused_at_post_registration_check() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + // Assertion with "test-issuer"; the key IS in the deny map. + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let state = audio_deny_state(Some(&key.public_key())).await; + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + // Sign with the SAME key as the assertion — pairing passes. + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Receive the denial frame. + let frame = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("W_audio_deny_active: denial frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_denied = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_denied.as_str(), + "W_audio_deny_active: active deny entry must produce exact \ + authorization_denied frame at post-registration check" + ); + } + other => panic!("W_audio_deny_active: expected Text(restricted JSON); got {other:?}"), + } + + // Connection must close after denial. + let close = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()).await; + assert!( + matches!( + close, + Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_)))) + | Ok(Some(Err(_))) + | Ok(None) + ), + "W_audio_deny_active: connection must close after denial; got {close:?}" + ); + + assert!( + cancel_for_assert.is_cancelled(), + "W_audio_deny_active: conn_cancel must be cancelled after denial" + ); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn w_audio_deny_absent_key_passes_deny_check_reaches_membership_gate() { + // A key NOT in the deny map must pass the deny-set check and reach the + // post-check / membership-entry gate without denial or cancellation. + // + // Two hooks bracket the deny-set check block: + // 1. `before_deny_set_check` (pre-check): proves the handler reached + // the deny-check seam after pairing + registration; connection is + // NOT cancelled here. + // 2. `after_deny_set_check_passed` (post-check): fires only when the + // key was NOT denied — proves the handler continued past the check + // without a denial or cancel. An unconditional denial immediately + // after the pre-check hook would prevent this hook from firing. + // + // Mutation evidence: + // A) Invert `is_denied` → absent key is denied after pre-check hook + // releases → handler returns early → post-check hook NEVER fires → + // `post_arrived_rx` times out → test panics. + // B) Delete the `before_deny_set_check` hook → pre-check `arrived_rx` + // times out → test panics (seam unreachable). + // C) Delete the `after_deny_set_check_passed` hook → post-check + // `post_arrived_rx` times out → test panics (pass-through unproven). + // D) Remove `nip_fi_deny_map` from state → map is None → guard + // short-circuits → both hooks still fire (map guard is after both + // hooks are in the control path) — off-mode passes through cleanly. + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + // Different key is denied; `key` is absent from the map. + let other_key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let state = audio_deny_state(Some(&other_key.public_key())).await; + + // Use a unique UUID so this test's hook slot doesn't collide with + // other concurrent tests (active test uses Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Arm BOTH hooks before sending the auth message. + // Hook 1: pre-check barrier — fires when handler reaches before_deny_set_check. + let (pre_arrived_rx, pre_release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + // Hook 2: post-check barrier — fires when handler passes deny check (key absent). + let (post_arrived_rx, post_release) = + crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // === Pre-check seam === + // Wait for handler to reach before_deny_set_check. + // Proves: pairing passed, registration happened, deny check reached. + tokio::time::timeout(std::time::Duration::from_secs(5), pre_arrived_rx) + .await + .expect("W_audio_deny_absent: handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Connection is NOT cancelled at the pre-check seam. + assert!( + !cancel_for_assert.is_cancelled(), + "W_audio_deny_absent: connection must NOT be cancelled at the pre-check seam" + ); + + // Release pre-check hook — handler proceeds to run the deny check. + pre_release.notify_one(); + + // === Post-check seam === + // Wait for handler to reach after_deny_set_check_passed. + // This hook ONLY fires if the key was NOT denied. An inverted `is_denied` + // would deny the absent key and return early, never reaching this hook. + tokio::time::timeout(std::time::Duration::from_secs(5), post_arrived_rx) + .await + .expect( + "W_audio_deny_absent: handler must reach after_deny_set_check_passed within 5s \ + (absent key must pass the deny check without denial)", + ) + .expect("post-check arrived channel closed"); + + // Connection is STILL not cancelled — the absent key passed clean. + assert!( + !cancel_for_assert.is_cancelled(), + "W_audio_deny_absent: connection must NOT be cancelled after the deny check \ + (absent key must pass clean)" + ); + + // Release post-check hook — handler proceeds to membership check (lazy DB). + post_release.notify_one(); + + // Allow the handler to proceed briefly (lazy-DB membership error is expected; + // that path is out of scope for this witness). + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn w_audio_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { + // Arms `before_deny_set_check` — fires AFTER audio_post_auth_register and + // BEFORE the is_denied call. Entry starts absent; inserted during the window. + // The deny check finds it and closes the connection. + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // Build state with empty deny map (key not denied yet). + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + let deny_map_for_insert = Arc::clone(&deny_map); + + let mut base_state = (*audio_test_state().await).clone(); + base_state.nip_fi_deny_map = Some(deny_map); + let state = Arc::new(base_state); + + // Use a unique UUID so this test's hook slot doesn't collide with + // other concurrent tests (absent/active tests use Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + // Pre-create and register the CommunityConnectionControl before the server + // runs. audio_post_auth_register writes proven_pubkey on the control; since + // Clone shares the same proven_pubkey Arc, the registered entry is updated + // in-place and disconnect_nip_fi can find it at the close-scan assertion. + // The guard keeps the entry live through that assertion. + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id_for_registration = uuid::Uuid::new_v4(); + let _conn_guard = state.community_connections.register( + conn_id_for_registration, + community, + conn_control.clone(), + ); + let conn_control_for_server = conn_control.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + // Use the pre-registered control so audio_post_auth_register + // writes to the registered entry (shared proven_pubkey Arc). + let control_inner = control_outer.clone(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Arm the barrier BEFORE sending auth (handler stalls when it reaches the hook). + let (arrived_rx, release) = crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Wait for the handler to reach before_deny_set_check (after registration). + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W_audio_deny_straddle: handler must reach hook within 5s") + .expect("arrived channel closed"); + + // Insert the deny entry — handler is between registration and check. + let until = Utc::now() + Duration::seconds(3600); + let merge = deny_map_for_insert.merge_cross_pod_deny( + "test-issuer", + &key.public_key(), + until, + Utc::now(), + ); + assert!( + matches!(merge, buzz_auth::CrossPodMergeResult::Merged), + "W_audio_deny_straddle: deny entry must be inserted during hook window" + ); + + // Close-scan side: run the real CommunityConnectionRegistry::disconnect_nip_fi + // now that the audio connection is registered (audio_post_auth_register fired + // before the hook). This proves registration is visible to the concurrent close + // scan — the normative invariant [FI-TRACE-DENY-SET] for the audio path. + // With the deny entry live, the scan finds exactly one session matching this + // pubkey and closes it. + // + // Mutation evidence (Mut-C: move hook before audio_post_auth_register): + // disconnect_nip_fi returns 0 (not yet registered) → assertion panics. + // Causally falsifies the registration-before-check invariant. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_audio_deny_straddle: close scan must find exactly 1 registered audio session \ + (proves audio_post_auth_register is visible between the hook and the check)" + ); + + // Release — handler resumes and calls is_denied(). + release.notify_one(); + + // Receive the denial frame from the server. + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_audio_deny_straddle: denial frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_denied = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_denied.as_str(), + "W_audio_deny_straddle: deny entry inserted between registration \ + and check must produce exact authorization_denied frame" + ); + } + other => panic!("W_audio_deny_straddle: expected Text(restricted JSON); got {other:?}"), + } + + assert!( + cancel_for_assert.is_cancelled(), + "W_audio_deny_straddle: conn_cancel must be cancelled after straddle denial" + ); + + server.abort(); + let _ = server.await; + } + + // ── W_admin_disconnect_at_deny_check: pre-registration-window is now closed ── + // + // Witnesses that a disconnect_nip_fi call that fires at the `before_deny_set_check` + // hook window — AFTER audio_post_auth_register (pubkey scan-visible) but BEFORE + // the deny-set check — still delivers the restricted JSON payload before the 1008 + // close. This is the exact window Thufir identified as the pre-registration gap + // in pass 2: the old code registered the terminal sender AFTER this point, so + // `disconnect_nip_fi` found `terminal_frame_tx = None` and queued nothing. The + // fix moves sender registration to BEFORE `audio_post_auth_register`, closing + // the window. + // + // Setup: + // - Pre-create and register control (same pattern as straddle/admin_disconnect). + // - Key absent from deny map. 1-hour deadline — expiry does not fire. + // - Arm `before_deny_set_check` hook. This hook fires AFTER both + // `set_terminal_frame_sender` and `audio_post_auth_register`. + // - While handler is held at the hook, call `registry.disconnect_nip_fi`. + // - Release; handler hits check_cancel!(), drains denial frame, emits 1008. + // - Client asserts Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // + // Mutation evidence: + // A) Move `set_terminal_frame_sender` to AFTER the hook window (into the B1 + // block, after the deny-set check, where it was in the original pass-1 code) + // → when disconnect_nip_fi fires at the before_deny_set_check window, the + // slot is still `None` → nothing enqueued → check_cancel!() drains nothing + // → client receives only 1008 with no preceding Text frame → frame-0 Text + // assertion panics. + // B) Remove `set_terminal_frame_sender` entirely → same outcome as (A). + #[tokio::test] + async fn w_admin_disconnect_at_deny_check_delivers_payload_then_close() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // State with deny map; key is absent (not denied) — the check must pass. + let state = audio_deny_state(None).await; + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + + // Pre-create and register the control so the pubkey-scan can find it. + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = uuid::Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("W_addc: bind test listener"); + let addr = listener.local_addr().expect("W_addc: test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("W_addc: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("W_addc: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("W_addc: connect client"); + + // Arm before_deny_set_check — fires AFTER set_terminal_frame_sender AND + // audio_post_auth_register (pubkey scan-visible). This is the exact window + // where the old code had terminal_frame_tx = None. + let (hook_arrived_rx, hook_release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // NIP-42 challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_addc: challenge timeout") + .expect("W_addc: challenge item") + .expect("W_addc: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("W_addc: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("W_addc: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("W_addc: challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("W_addc: send auth"); + + // Wait for the handler to reach before_deny_set_check. + // At this point BOTH set_terminal_frame_sender and audio_post_auth_register + // have already executed — the terminal sender is registered and the pubkey + // is scan-visible. (In the old code this was the gap window.) + tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) + .await + .expect("W_addc: handler must reach before_deny_set_check within 5s") + .expect("W_addc: hook arrived channel closed"); + + // Simulate admin-disconnect at the exact old-gap position. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_addc: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + // Release — handler resumes, hits check_cancel!(), drains the enqueued + // denial frame, sends reason.close_message(). + hook_release.notify_one(); + + // Frame 0: restricted JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_addc: frame 0 timeout") + .expect("W_addc: frame 0 item") + .expect("W_addc: frame 0 ws message"); + let expected_json = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_json.as_str(), + "W_addc: frame 0 must be exact restricted JSON (terminal sender was \ + registered before scan-visibility, so the old gap is closed)" + ); + } + other => { + panic!("W_addc: frame 0 must be Text(restricted JSON); got {other:?}") + } + } + + // Frame 1: 1008 POLICY close. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_addc: frame 1 timeout") + .expect("W_addc: frame 1 item") + .expect("W_addc: frame 1 ws message"); + match frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "W_addc: close code must be 1008 POLICY" + ); + assert_eq!( + >::as_ref(&cf.reason), + "authorization denied", + "W_addc: close reason must be exact 'authorization denied' bytes" + ); + } + other => { + panic!("W_addc: frame 1 must be Close(1008, 'authorization denied'); got {other:?}") + } + } + + assert!( + cancel_for_assert.is_cancelled(), + "W_addc: conn_cancel must be cancelled after admin disconnect" + ); + + server.abort(); + let _ = server.await; + } + + // ── W_admin_disconnect: registry disconnect_nip_fi delivers payload-then-close ─ + // + // Witnesses that an active audio socket closed via the admin-disconnect path + // (`CommunityConnectionRegistry::disconnect_nip_fi`) delivers the restricted + // JSON payload BEFORE the 1008 POLICY close — the payload-then-close contract. + // + // Before this fix, `CommunityConnectionControl::disconnect_nip_fi` only + // published `AuthorizationDenied` + cancelled; no frame was enqueued on the + // terminal channel. The send loop (or pre-send-loop drain) then emitted only + // the close, with no preceding restricted JSON frame. + // + // Setup: + // - Pre-create and register `CommunityConnectionControl` (so the registry + // scan can find this audio session by pubkey — same pattern as straddle). + // - Key absent from deny map. Assertion carries a 1-hour deadline so the + // expiry task is armed but does NOT fire during the test. + // - `before_first_audio_check_cancel` hook holds the handler AFTER + // `set_terminal_frame_sender` registers the sender on the control (line + // ~421) and BEFORE the first `check_cancel!()`. + // - Test calls `registry.disconnect_nip_fi("test-issuer", &pubkey)` while the hook holds. + // `CommunityConnectionControl::disconnect_nip_fi` enqueues the denial frame + // on `terminal_frame_tx`, publishes `AuthorizationDenied`, then cancels. + // - Hook is released; handler hits `check_cancel!()`, drains the denial + // frame from `terminal_ctrl_rx`, sends `reason.close_message()`. + // - Client asserts: Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // + // Mutation evidence (production seam, not copies): + // A) Remove the `set_terminal_frame_sender` call from `handle_active_audio_connection` + // → `terminal_frame_tx` slot is `None` → `disconnect_nip_fi` enqueues nothing + // → client receives only `1008` with no preceding text frame → Text assertion + // times out → panics. + // B) Remove the `try_send` block from `CommunityConnectionControl::disconnect_nip_fi` + // → same outcome as (A): enqueue suppressed → only close observed → panics. + // C) Delete the `while let Ok(msg) = terminal_ctrl_rx.try_recv()` drain from the + // plain `check_cancel!()` arm → no text frame delivered → panics. + // D) Move `set_terminal_frame_sender` to AFTER `audio_post_auth_register` + // (back to the pass-1 ordering) → when disconnect_nip_fi fires at the + // `before_first_audio_check_cancel` hook (which itself is after the old + // registration point), the sender IS registered → test still PASSES. + // Use W_admin_disconnect_at_deny_check (hook at before_deny_set_check, + // the old gap) to catch this regression instead — that witness is RED + // under the pass-1 ordering. (See W_addc above.) + #[tokio::test] + async fn admin_disconnect_nip_fi_delivers_restricted_json_then_policy_close() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + // 1-hour deadline: expiry task armed but will NOT fire during this test. + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // State with deny map; key is absent (not denied). + let state = audio_deny_state(None).await; + + // Unique community so hook and registry slots don't collide with parallel tests. + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + + // Pre-create and register the control so the pubkey-scan can find it. + // `audio_post_auth_register` writes `proven_pubkey` on this same Arc; + // the registered entry is updated in-place. [same pattern as straddle test] + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = uuid::Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("W_admin_disconnect: bind test listener"); + let addr = listener + .local_addr() + .expect("W_admin_disconnect: test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("W_admin_disconnect: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("W_admin_disconnect: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("W_admin_disconnect: connect client"); + + // Arm the hook BEFORE sending auth — it fires after `set_terminal_frame_sender` + // registers the sender (now before audio_post_auth_register, line ~343) and + // before the first `check_cancel!()`. + let (hook_arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_before_first_check_cancel_hook::arm(community); + + // NIP-42 challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_admin_disconnect: challenge timeout") + .expect("W_admin_disconnect: challenge item") + .expect("W_admin_disconnect: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("W_admin_disconnect: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("W_admin_disconnect: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("W_admin_disconnect: challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("W_admin_disconnect: send auth"); + + // Wait for the handler to reach before_first_audio_check_cancel. + // At this point `set_terminal_frame_sender` has already been called and + // the terminal sender is registered on the control. + tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) + .await + .expect( + "W_admin_disconnect: handler must reach before_first_audio_check_cancel within 5s", + ) + .expect("W_admin_disconnect: hook arrived channel closed"); + + // Simulate admin-disconnect: call the real registry disconnect scan by pubkey. + // CommunityConnectionControl::disconnect_nip_fi enqueues the denial frame on + // the registered terminal sender, publishes AuthorizationDenied, then cancels. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_admin_disconnect: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + // Release hook — handler resumes, hits check_cancel!(), drains the + // enqueued denial frame, then sends reason.close_message(). + hook_release.notify_one(); + + // Frame 0: restricted JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_admin_disconnect: frame 0 timeout") + .expect("W_admin_disconnect: frame 0 item") + .expect("W_admin_disconnect: frame 0 ws message"); + let expected_json = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_json.as_str(), + "W_admin_disconnect: frame 0 must be exact restricted JSON payload" + ); + } + other => { + panic!("W_admin_disconnect: frame 0 must be Text(restricted JSON); got {other:?}") + } + } + + // Frame 1: 1008 POLICY close. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_admin_disconnect: frame 1 timeout") + .expect("W_admin_disconnect: frame 1 item") + .expect("W_admin_disconnect: frame 1 ws message"); + match frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "W_admin_disconnect: close code must be 1008 POLICY" + ); + assert_eq!( + >::as_ref(&cf.reason), + "authorization denied", + "W_admin_disconnect: close reason must be exact 'authorization denied' bytes" + ); + } + other => panic!( + "W_admin_disconnect: frame 1 must be Close(1008, 'authorization denied'); \ + got {other:?}" + ), + } + + assert!( + cancel_for_assert.is_cancelled(), + "W_admin_disconnect: conn_cancel must be cancelled after admin disconnect" + ); + + server.abort(); + let _ = server.await; + } + + // ── W9/W10/reaffirm: participant-commit barrier (real-DB) ───────────────── + // + // These three witnesses require a seeded DB (community + channel + membership). + // They require the PG-wrapper DB, like W1; a missing or unreachable DB fails. + // + // Shared fixture setup for W9, W10, and the reaffirm variant: + // 1. INSERT a community (non-nil UUID, `deletion_state = 'active'`). + // 2. INSERT a channel under that community (no TTL → non-ephemeral, so + // `check_membership_for_admission` returns `MembershipAdmission::Existing` + // which we pass directly without going through that function). + // 3. INSERT the test pubkey into `channel_members` so the `Existing` path + // is correct and `commit_participant_join` goes straight to the 48101 insert. + // 4. Call `commit_participant_join` directly (it is `pub(crate)` for tests). + + /// Create an AppState backed by the PG lane wrapper's per-test database. + /// + /// A missing or unreachable DB panics — these witnesses never skip. [F3 fix] + async fn audio_test_state_real_db() -> std::sync::Arc { + use std::sync::Arc; + + let db_url = crate::test_support::required_database_url(); + if let Err(e) = sqlx::PgPool::connect(&db_url).await { + panic!("wrapper DB {db_url} unreachable: {e}"); + } + // hermetic_for_test: env-free, never races NIP-FI env-var mutations. + // Override database_url to the wrapper DB probed above. [F6] + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + config.database_url = db_url; + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + /// Seed a community + channel + membership row. Returns `(pool, tenant, channel_id, pubkey_bytes)`. + async fn seed_audio_fixture( + pool: &sqlx::PgPool, + ) -> (buzz_core::tenant::TenantContext, uuid::Uuid, nostr::Keys) { + let community_uuid = uuid::Uuid::new_v4(); + let host = format!("w9-test-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(pool) + .await + .expect("W9 fixture: seed community"); + + let channel_id = uuid::Uuid::new_v4(); + let creator = nostr::Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'w9-test-channel', 'stream', 'open', $3)", + ) + .bind(channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(pool) + .await + .expect("W9 fixture: seed channel"); + + let member_key = nostr::Keys::generate(); + let member_bytes = member_key.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_uuid) + .bind(channel_id) + .bind(&member_bytes) + .bind(&creator_bytes) + .execute(pool) + .await + .expect("W9 fixture: seed channel_member"); + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(community_uuid), + host, + ); + (tenant, channel_id, member_key) + } + + /// Like `seed_audio_fixture` but with `visibility = 'private'` so that a + /// non-member key fails `check_membership_for_admission` with "not a member" + /// rather than being auto-admitted via the open-channel shortcut. + /// + /// Used by the R2 `drain_terminal!()` race witness, which needs to exercise + /// the Err arm of `check_membership_for_admission`. + async fn seed_private_audio_fixture( + pool: &sqlx::PgPool, + ) -> (buzz_core::tenant::TenantContext, uuid::Uuid, nostr::Keys) { + let community_uuid = uuid::Uuid::new_v4(); + let host = format!("r2-private-test-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(pool) + .await + .expect("R2 private fixture: seed community"); + + let channel_id = uuid::Uuid::new_v4(); + let creator = nostr::Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'r2-private-channel', 'stream', 'private', $3)", + ) + .bind(channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(pool) + .await + .expect("R2 private fixture: seed channel"); + + let member_key = nostr::Keys::generate(); + let member_bytes = member_key.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_uuid) + .bind(channel_id) + .bind(&member_bytes) + .bind(&creator_bytes) + .execute(pool) + .await + .expect("R2 private fixture: seed channel_member"); + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(community_uuid), + host, + ); + (tenant, channel_id, member_key) + } + + // ── W9: expiry between uncommitted 48101 insert and acquire_effect → rollback ── + // + // `before_participant_commit` fires between the uncommitted 48101 insert and + // `acquire_effect()`. Firing expiry at that point must roll back the + // transaction (no committed 48101 row in the DB) and return + // `JoinCommitError::Expired` to the caller. + // + // Mutation evidence: + // A) Delete `before_participant_commit(...)` from commit_participant_join → + // hook never fires → `arrived_rx` times out → test panics. + // B) Remove `tx.rollback()` from the `SessionExpired` branch → + // transaction auto-commits at drop, leaving a 48101 row → row-count + // assertion panics. + // C) Remove `acquire_effect()` entirely → commit proceeds despite cancel → + // a row is committed → row-count assertion panics. + async fn w9_expiry_before_participant_commit_rolls_back_48101_insert_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + let member_bytes = member_key.public_key().to_bytes().to_vec(); + let member_hex = member_key.public_key().to_hex(); + let peer_id = Uuid::new_v4(); + let roster_revision = 1u64; + let membership = MembershipAdmission::Existing { + parent_channel_id: channel_id, + }; + + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Arm the hook: fires between the uncommitted 48101 insert and acquire_effect. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_participant_commit_hook::arm(community_id); + + let state2 = Arc::clone(&state); + let tenant2 = tenant.clone(); + let member_bytes2 = member_bytes.clone(); + let member_hex2 = member_hex.clone(); + let gate2 = Arc::clone(&gate); + let handle = tokio::spawn(async move { + commit_participant_join( + &state2, + &tenant2, + channel_id, + channel_id, + &member_hex2, + &member_bytes2, + peer_id, + roster_revision, + "test-generation", + &membership, + &gate2, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant2.community(), + channel_id, + )), + ) + .await + }); + + // Wait for the handler to reach the hook. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("W9: commit_participant_join must reach before_participant_commit within 10s") + .expect("arrived channel closed"); + + // Fire expiry — acquire_effect will return SessionExpired after release. + cancel.cancel(); + + // Release — handler resumes, calls acquire_effect(), gets SessionExpired, rolls back. + release.notify_one(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(10), handle) + .await + .expect("W9: commit_participant_join must return within 10s after hook release") + .expect("commit_participant_join task must not panic"); + + // Must return Expired, not Ok. + assert!( + matches!(result, Err(JoinCommitError::Expired)), + "W9: commit_participant_join must return JoinCommitError::Expired after mid-flight expiry; got: {result:?}" + ); + + // Zero committed 48101 rows for this community+channel — transaction was rolled back. + let row_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("W9: row count query"); + + assert_eq!( + row_count, 0, + "W9: no 48101 row must be committed after expiry-forced rollback; found {row_count}" + ); + + // No membership side effects from commit (membership was Existing — no new insert). + // The pre-existing channel_members row must still be there (rollback only undoes the tx's own writes). + let member_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(&member_bytes) + .fetch_one(&pool) + .await + .expect("W9: member count query"); + + assert_eq!( + member_count, 1, + "W9: the pre-seeded membership row must survive the rollback" + ); + } + + // ── F3: lifecycle_generation in committed 48101 JOIN ────────────────────── + // + // Verifies that `commit_participant_join` includes `lifecycle_generation` in + // the 48101 event content in Off mode, and that the committed generation + // matches `state.huddle_liveness_generation` — the SAME source the liveness + // consumer (`handle_huddle_liveness_req`) uses for Off-mode (non-mesh) rooms. + // This proves Desktop reconciliation will NOT clear admissions: the + // generation embedded in the JOIN and the generation returned by the next + // authoritative liveness response are always equal. + // + // Without this field Desktop reconciliation assigns "pending" to the joining + // peer and then clears admissions when the next authoritative liveness + // response (which includes the real generation) differs from the JOIN's + // absent generation. + // + // Mutation evidence: + // Remove `"generation": lifecycle_generation` from the content JSON in + // `commit_participant_join` → the field is absent → the assertion on + // `content_json["generation"]` panics. + // Use a hard-coded string instead of `state.huddle_liveness_generation` → + // the lifecycle/liveness equality assertion panics (generation mismatch). + async fn f3_commit_participant_join_includes_lifecycle_generation_body() { + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + let pubkey_hex = member_key.public_key().to_hex(); + let pubkey_bytes = member_key.public_key().to_bytes().to_vec(); + + // Use the relay's global liveness generation — the SAME source that + // `handle_huddle_liveness_req` returns for Off-mode (non-mesh) rooms. + // This binds the test to the production path: audio/handler.rs line 716 + // also falls back to `state.huddle_liveness_generation.to_string()` for + // Off-mode sessions. A hard-coded string would break the equality proof. + let generation_string = state.huddle_liveness_generation.to_string(); + + // Capture the explicit peer_id and roster_revision driven by this test + // so we can assert them against the committed 48101 content (independence + // check: the content stores what the test drove, not some other value). + let expected_peer_id = Uuid::new_v4(); + let expected_roster_revision: u64 = 1; + + // Off-mode gate (no deadline → acquire_effect always succeeds). + let off_cancel = tokio_util::sync::CancellationToken::new(); + let off_gate = crate::nip_fi_gate::SessionAdmissionGate::off_mode(off_cancel.clone()); + + let result = commit_participant_join( + &state, + &tenant, + channel_id, + channel_id, + &pubkey_hex, + &pubkey_bytes, + expected_peer_id, + expected_roster_revision, + &generation_string, + &MembershipAdmission::Existing { + parent_channel_id: channel_id, + }, + &off_gate, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new(community_id, channel_id)), + ) + .await; + + assert!( + result.is_ok(), + "F3: commit_participant_join must succeed; got: {result:?}" + ); + + // Retrieve the committed 48101 row and check its content and tags. + struct F3Row { + pubkey_bytes: Vec, + tags_json: serde_json::Value, + content: String, + } + let row: F3Row = { + let r = sqlx::query( + "SELECT pubkey, tags, content::text \ + FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101 \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("F3: 48101 row must exist after commit"); + F3Row { + pubkey_bytes: r.get::, _>(0), + tags_json: r.get::(1), + content: r.get::(2), + } + }; + + let content_json: serde_json::Value = + serde_json::from_str(&row.content).expect("F3: 48101 content must be valid JSON"); + + // Primary assertion: generation field matches the relay's liveness generation. + // The liveness consumer (handle_huddle_liveness_req) returns the same + // `state.huddle_liveness_generation` for Off-mode rooms — so the JOIN's + // embedded generation will equal the next liveness response, and Desktop + // reconciliation will NOT clear the admission. [F3: liveness-consumer equality] + // + // Wire key is "generation" — matches Desktop's lifecycleContent parser + // at huddlePresence.ts:83-85 (`content.generation`). [Carl review] + assert_eq!( + content_json["generation"].as_str(), + Some(generation_string.as_str()), + "F3: 48101 JOIN content must include generation = {generation_string:?} \ + (must match state.huddle_liveness_generation, the same source used by the \ + Off-mode liveness consumer); got content: {}", + row.content + ); + + // roster_revision: exact value driven by this test (not just "is some"). + // If commit_participant_join stores a different value, this fails. + assert_eq!( + content_json["roster_revision"].as_u64(), + Some(expected_roster_revision), + "F3: 48101 JOIN content must include roster_revision = {expected_roster_revision}; \ + got content: {}", + row.content + ); + + // admission_id: must be the UUID we passed in (independently expected). + // This catches any path that discards or replaces the peer_id argument. + assert_eq!( + content_json["admission_id"].as_str(), + Some(expected_peer_id.to_string().as_str()), + "F3: 48101 JOIN content must include admission_id = {expected_peer_id}; \ + got content: {}", + row.content + ); + + // p-tag: must be the member pubkey (not the relay signer or any other key). + // This is the independence check for Thufir's identity finding: + // participantPubkey(event) = tags.find(t => t[0] == "p")?.[1] ?? event.pubkey + // The p-tag must be member_key, not relay_signer or anything else. + // + // Mutation (Thufir's named counterexample): change p_tag to use the relay + // signer at audio/handler.rs:2525-2532 → participant_hex != member pubkey + // → this assertion panics. + let p_tag_value: Option = row + .tags_json + .as_array() + .and_then(|tags| { + tags.iter().find(|tag| { + tag.as_array() + .and_then(|t| t.first()) + .and_then(|v| v.as_str()) + == Some("p") + }) + }) + .and_then(|tag| tag.as_array()) + .and_then(|t| t.get(1)) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + assert_eq!( + p_tag_value.as_deref(), + Some(pubkey_hex.as_str()), + "F3: 48101 p-tag must be the member's pubkey ({pubkey_hex}), \ + not the relay signer or any other key. \ + Mutation (Thufir): change p_tag at audio/handler.rs:2525-2532 to relay signer \ + → p_tag_value != pubkey_hex → assertion panics." + ); + + // 48101 must be signed by the relay keypair (pubkey column = relay signer). + let relay_signer_hex = state.relay_keypair.public_key().to_hex(); + let join_pubkey_hex = hex::encode(&row.pubkey_bytes); + assert_eq!( + join_pubkey_hex, relay_signer_hex, + "F3: 48101 event must be signed by the relay keypair" + ); + + // ── Drift guard: producer output shape must match committed fixture ───────── + // + // The committed fixture `desktop/src/features/huddle/lib/__fixtures__/nip-fi-join-liveness.json` + // is the input for the Desktop contract witness + // (`F3 Desktop contract: producer JOIN + liveness retain admitted participant + // through authoritative refresh` in huddlePresenceRuntime.test.mjs). + // + // This drift guard ensures that if the producer schema changes (keys renamed, + // dropped, or retyped), the Desktop test will also fail — preventing silent + // divergence where the Desktop test passes against stale fixture data. + // + // Structural invariants asserted here: + // - content has exactly the keys: ephemeral_channel_id, roster_revision, + // admission_id, generation (no extra, no missing) + // - p-tag is present and non-empty (already asserted above) + // - event pubkey is relay signer (already asserted above) + // + // RED gate (b): change the producer's `generation` wire key or value + // (e.g., rename "generation" to "gen") → content_json["generation"] is null → + // the generation assertion above panics. This breaks the Desktop test too, since + // the fixture will no longer match what the producer emits. + let content_keys: std::collections::BTreeSet = content_json + .as_object() + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default(); + let expected_keys: std::collections::BTreeSet = [ + "ephemeral_channel_id", + "roster_revision", + "admission_id", + "generation", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + content_keys, expected_keys, + "F3 drift guard: 48101 JOIN content must have exactly the keys \ + {{ephemeral_channel_id, roster_revision, admission_id, generation}}. \ + The committed Desktop fixture (nip-fi-join-liveness.json) encodes this shape. \ + If the producer adds, removes, or renames a key, update the fixture \ + AND this guard in the same commit. Got keys: {content_keys:?}" + ); + } + + // ── F3 wire: real admission produces 48101 JOIN with correct generation ── + // + // Drives the real `handle_active_audio_connection` handler through a full + // admission to a committed 48101 JOIN event. Pauses at + // `after_participant_fanout` (TX committed, fan-out done), then reads the + // actual 48101 wire bytes from the DB and asserts that + // `content.generation` equals `state.huddle_liveness_generation.to_string()`. + // + // This directly tests the production Off-mode fallback at + // `audio/handler.rs:802`: + // `state.huddle_liveness_generation.to_string()` + // + // Falsifying counterexample per Thufir: change line 802 to a hardcoded UUID. + // The existing `f3_commit_participant_join_includes_lifecycle_generation` test + // supplies `state.huddle_liveness_generation` as its INPUT and would not + // detect this regression. THIS test reads the committed content FROM the wire + // bytes and compares it against the same source — the hardcoded UUID would + // disagree, and this test panics. + // + // Mutation evidence: + // A) Change `audio/handler.rs:802` to a hardcoded UUID → + // committed 48101 content.generation != state.huddle_liveness_generation → + // assertion panics. + // B) Remove `"generation": lifecycle_generation` from commit content → + // `content_json["generation"]` is null → as_str() returns None → + // assert_eq panics (generation_from_wire != expected_generation). + // C) Delete `after_participant_fanout(...)` → arrived_rx times out → panics. + // + // Requires Postgres for the full commit path. + async fn f3_wire_admission_generation_matches_liveness_source_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community = tenant.community(); + + // Far-future deadline — admission completes before any expiry. + let assertion = VerifiedAssertion::for_test( + Some(member_key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let conn_cancel = CancellationToken::new(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + let conn_cancel_c = conn_cancel.clone(); + let tenant_host = tenant_c.host().to_string(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("F3 wire: bind listener"); + let addr = listener.local_addr().expect("F3 wire: listener addr"); + + // Arm after_participant_fanout BEFORE spawning server to avoid a race. + let (fanout_rx, fanout_release) = + crate::nip_fi_test_hooks::audio_participant_fanout_hook::arm(community); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel_c.clone(); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("F3 wire: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("F3 wire: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("F3 wire: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("F3 wire: challenge timeout") + .expect("F3 wire: challenge item") + .expect("F3 wire: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("F3 wire: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("F3 wire: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("F3 wire: challenge field") + .to_string(); + + let relay_url = format!("ws://{tenant_host}"); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&member_key) + .unwrap(); + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + "parent_channel_id": null, + "protocol_version": 1, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("F3 wire: send auth"); + + // Wait for after_participant_fanout: 48101 is committed and fan-out done. + // The production Off-mode generation selection (audio/handler.rs:802) has run. + tokio::time::timeout(std::time::Duration::from_secs(10), fanout_rx) + .await + .expect( + "F3 wire: handler must reach after_participant_fanout within 10s (proves commit path ran and 48101 is committed with production generation)", + ) + .expect("F3 wire: fanout channel closed"); + + // ── Read the actual 48101 wire bytes from DB ────────────────────── + let content_str: String = sqlx::query_scalar( + "SELECT content::text FROM events WHERE community_id = $1 AND channel_id = $2 AND kind = 48101 ORDER BY created_at DESC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("F3 wire: 48101 row must exist after commit"); + + let content_json: serde_json::Value = + serde_json::from_str(&content_str).expect("F3 wire: 48101 content must be valid JSON"); + + // The generation in the wire bytes comes from production audio/handler.rs:802. + // This is the Off-mode fallback: state.huddle_liveness_generation.to_string(). + // If that line is changed to a hardcoded UUID, this assertion fails. + let generation_from_wire = content_json["generation"] + .as_str() + .unwrap_or("") + .to_string(); + + // The authoritative source that both the admission handler and the liveness + // consumer (req.rs handle_huddle_liveness_req) use for Off-mode rooms. + let expected_generation = state.huddle_liveness_generation.to_string(); + + // ── F3 consumer chain: liveness oracle returns same generation ───────── + // + // Extends the producer chain above with the consumer side: + // real admission → actual 48101 JOIN wire bytes → + // hydrated Desktop-semantics oracle (handle_huddle_liveness_req) → + // authoritative liveness generation → equals wire generation → + // Desktop will NOT clear the admission. + // + // Contract (huddlePresence.ts:464-484): + // Desktop records JOIN's content.generation. On each liveness event, + // it compares event.content.generation against the stored value. + // Mismatch → peer cleared. Equal → admission retained. + // + // Step 1: The real handler called add_peer during the admission; the room + // is live with the member_key peer. Verify the admission is retained. + let room_after_commit = state.audio_rooms.get(community, channel_id); + assert!( + room_after_commit.is_some_and(|r| !r.is_empty()), + "F3 wire: audio room must be non-empty after admission commit \ + (proves add_peer ran — admission is retained in the room roster)" + ); + + // Step 2: Seed a KIND_HUDDLE_STARTED link so handle_huddle_liveness_req + // can find the session via huddle_started_links(). + // This fixture uses channel_id as its own parent (non-ephemeral channel). + // The link event content specifies ephemeral_channel_id = channel_id. + // creator_bytes is derived from the channel row's created_by field. + let creator_bytes: Vec = sqlx::query_scalar( + "SELECT created_by FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("F3 wire consumer: fetch channel.created_by"); + + let link_content = serde_json::json!({ + "ephemeral_channel_id": channel_id.to_string() + }) + .to_string(); + let link_event_id = uuid::Uuid::new_v4().as_bytes().to_vec(); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7) \ + ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(&link_event_id[..]) + .bind(&creator_bytes) + .bind(buzz_core::kind::KIND_HUDDLE_STARTED as i32) + .bind(&link_content) + .bind(vec![0u8; 64]) + .bind(channel_id) // parent channel = session channel (non-ephemeral) + .execute(&pool) + .await + .expect("F3 wire consumer: seed KIND_HUDDLE_STARTED link event"); + + // Step 3: Build a minimal ConnectionState for the liveness call. + // (Same pattern as req.rs f3_liveness_consumer_generation_equals_join_generation.) + use std::collections::HashMap; + use tokio::sync::mpsc; + let lc_cancel = tokio_util::sync::CancellationToken::new(); + let lc_gate = crate::nip_fi_gate::SessionAdmissionGate::off_mode(lc_cancel.clone()); + let (send_tx, mut send_rx) = mpsc::channel::(16); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + let lc_conn = std::sync::Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, tenant_host.clone()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: member_key.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: lc_cancel.clone(), + backpressure_count: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: lc_gate, + community_control: crate::state::CommunityConnectionControl::new(lc_cancel.clone()), + }); + + // Step 4: Call the production liveness handler. + // Filter: kind=KIND_HUDDLE_LIVENESS, #d=[channel_id]. + // parent_channel_ids=[channel_id] (the channel is its own parent here). + use buzz_core::kind::KIND_HUDDLE_LIVENESS; + let liveness_filter: nostr::Filter = serde_json::from_value(serde_json::json!({ + "kinds": [KIND_HUDDLE_LIVENESS], + "#d": [channel_id.to_string()], + })) + .expect("F3 wire consumer: build liveness filter"); + crate::handlers::req::handle_huddle_liveness_req( + "f3-wire-liveness-sub", + &[liveness_filter], + &[channel_id], + &lc_conn, + &state, + ) + .await; + + // Step 5: Collect frames and find the liveness generation. + let mut liveness_gen: Option = None; + let mut liveness_event: Option = None; + loop { + match send_rx.try_recv() { + Ok(axum::extract::ws::Message::Text(t)) => { + let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default(); + if v[0].as_str() == Some("EVENT") { + liveness_event = Some(v[2].clone()); + let content: serde_json::Value = + serde_json::from_str(v[2]["content"].as_str().unwrap_or("{}")) + .unwrap_or_default(); + if let Some(gen) = content["generation"].as_str() { + liveness_gen = Some(gen.to_string()); + } + } + if v[0].as_str() == Some("EOSE") { + break; + } + } + Ok(_) => {} + Err(_) => break, + } + } + + // Step 6: Assert liveness generation equals wire generation. + // This closes the end-to-end chain: + // real admission → actual JOIN wire bytes (generation_from_wire) + // → liveness oracle (handle_huddle_liveness_req) returns the SAME + // generation → Desktop will NOT clear the admission. + // + // Desktop invariant (huddlePresence.ts:464-484): + // JOIN.content.generation == liveness.content.generation → admission retained. + // Mismatch → peer cleared (admission lost). + + // ── F3 Desktop-semantics oracle: full admission-retention chain ────── + // + // Extends the liveness-generation chain with a hydrated Desktop-semantics + // oracle that faithfully implements `HuddlePresenceTracker` from + // `desktop/src/features/huddle/lib/huddlePresence.ts:260-484`. + // + // Purpose: + // The liveness-generation assertion above proves relay equality: + // JOIN.content.generation == liveness.content.generation (both from + // state.huddle_liveness_generation.to_string()). + // But generation equality alone cannot prove admission retention: + // Desktop ALSO rejects a JOIN whose resolved participant is empty + // (`participantPubkey(event)` → null → `apply()` returns false at + // `huddlePresence.ts:341-344`). The oracle below exercises that path. + // + // Chain: + // real admission → actual 48101 JOIN event (committed to DB and fetched here) + // → hydrated HuddlePresenceOracle (faithfully implements TS apply() + reconcileLiveness()) + // → reconcile with authoritative liveness generation (liveness_gen_value) + // → assert participant admission is retained (present = true) + // + // Acceptance RED: + // Strip the `p` tag from the JOIN event → the participant resolves to + // the relay signer (`?? event.pubkey`) → the identity assertion below + // panics. This is Thufir's named counterexample + // (audio/handler.rs:2525-2532 p-tag removal). + // + // The oracle struct below implements the minimal subset of + // HuddlePresenceTracker semantics required for this chain: + // - apply(STARTED) → creates session, records startCreator + // - apply(JOIN/48101) → checks event.pubkey == relaySelf AND the + // caller-resolved participant is non-empty AND (generation compatible) + // → records admission as present + // - reconcile_liveness(session_id, generation) → clears admissions on + // generation mismatch; leaves them if generation equals stored + // - is_admitted(session_id, participant) → true if present in admissions + + // Fetch the actual committed 48101 event from DB (includes pubkey + tags). + // The pubkey column is stored as raw bytes; tags as JSONB. + struct JoinRow { + pubkey_bytes: Vec, + tags_json: serde_json::Value, + content: String, + } + let join_row: JoinRow = { + let row = sqlx::query( + "SELECT pubkey, tags, content::text \ + FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101 \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("F3 oracle: 48101 row must exist for Desktop oracle step"); + JoinRow { + pubkey_bytes: row.get::, _>(0), + tags_json: row.get::(1), + content: row.get::(2), + } + }; + + // The relay signer's pubkey hex — must equal join_row.pubkey_bytes + // (all 48101 events are signed by state.relay_keypair). + let relay_signer_hex = state.relay_keypair.public_key().to_hex(); + let join_pubkey_hex = hex::encode(&join_row.pubkey_bytes); + + assert_eq!( + join_pubkey_hex, relay_signer_hex, + "F3 oracle: 48101 event must be signed by the relay keypair. \ + Mutation: change sign_with_keys to use a different key → pubkey mismatch → \ + oracle rejects JOIN (event.pubkey != relaySelf) → admission not recorded → \ + is_admitted panics below." + ); + + // Extract the p-tag participant from the stored event's tags JSON. + // huddlePresence.ts:participantPubkey(event) = tags.find(t => t[0] == "p")?.[1] ?? event.pubkey + // For relay-signed JOINs the tag is always present (audio/handler.rs:2525-2532). + let participant_from_p_tag: Option = join_row + .tags_json + .as_array() + .and_then(|tags| { + tags.iter().find(|tag| { + tag.as_array() + .and_then(|t| t.first()) + .and_then(|v| v.as_str()) + == Some("p") + }) + }) + .and_then(|tag| tag.as_array()) + .and_then(|t| t.get(1)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Desktop participantPubkey(): `tags.find(p)?.[1] ?? event.pubkey` + // (huddlePresence.ts:111-115). Only an absent p-tag falls back; an empty + // p-tag value stays empty and is rejected by `apply_join`. + let participant_hex = participant_from_p_tag.unwrap_or_else(|| join_pubkey_hex.clone()); + + // ── Identity independence check (Thufir round-7 finding) ───────────── + // The oracle reads participant_hex from the stored p-tag and then later + // asserts oracle.is_admitted(session_id, &participant_hex) — which grades + // its own homework if participant_hex is never compared against an + // independently expected value. This assertion breaks that circularity: + // participant_hex must equal member_key.public_key() — the key the test + // drove through the real admission handler. + // + // If the p-tag stores the relay signer instead of the member key + // (Thufir's counterexample at audio/handler.rs:2525-2532), participant_hex + // would not equal member_key.public_key().to_hex() and this assertion panics. + let expected_participant_hex = member_key.public_key().to_hex(); + assert_eq!( + participant_hex, expected_participant_hex, + "F3 identity: p-tag participant ({participant_hex}) must equal the independently \ + expected member_key ({expected_participant_hex}). \ + Mutation (Thufir's named counterexample): change the production JOIN p-tag at \ + audio/handler.rs:2525-2532 to use the relay signer → participant_hex is the relay \ + signer hex → != member pubkey → this assertion panics. \ + Without this check the oracle grades its own homework." + ); + + // ── Rust HuddlePresenceOracle ──────────────────────────────────────── + // Minimal faithful implementation of the Desktop semantics needed for this + // chain. Mirrors huddlePresence.ts:260-484 for the STARTED+JOIN+reconcile path. + // + // Key Desktop invariants exercised: + // 1. apply(STARTED) → session created with startCreator = event.pubkey. + // JOIN events are ONLY accepted for sessions where apply(STARTED) ran first + // (the `let session = self.sessions.get(session_id); if !session return false` + // guard at huddlePresence.ts:334-336). We seed STARTED manually here. + // 2. apply(JOIN): event.pubkey must equal relaySelf (ts:341). + // participant = participantPubkey(event) (p-tag ?? event.pubkey), resolved + // by the caller; must be non-empty (ts:344 `if !participant return false`). + // 3. reconcile_liveness: if generation equals stored → admissions preserved. + // if generation differs → admissions cleared (ts:464-484). + + struct HuddlePresenceOracle { + relay_signer_hex: String, + // session_id → (stored_generation, admitted_participants: HashMap) + sessions: std::collections::HashMap< + String, + (Option, std::collections::HashMap), + >, + } + impl HuddlePresenceOracle { + fn new(relay_signer_hex: String) -> Self { + Self { + relay_signer_hex, + sessions: Default::default(), + } + } + + // Apply a STARTED event to seed the session. + // ts:267-289: session created with startCreator = event.pubkey. + fn apply_started(&mut self, session_id: &str) { + self.sessions + .entry(session_id.to_string()) + .or_insert((None, Default::default())); + } + + // Apply a JOIN (48101) event. + // ts:335-476: + // - session must exist (seeded by apply_started above) + // - event.pubkey must == relaySelf (ts:341) + // - participant = caller-resolved participantPubkey (p-tag ?? pubkey); + // must be non-empty (ts:344) + // - generation stored/updated; admission recorded + // Returns true if admitted, false if rejected. + fn apply_join( + &mut self, + event_pubkey_hex: &str, + participant: &str, + content_json: &serde_json::Value, + ) -> bool { + let session_id = match content_json["ephemeral_channel_id"].as_str() { + Some(s) if !s.is_empty() => s.to_string(), + _ => return false, + }; + // ts:341: JOIN event.pubkey must equal relaySelf. + if event_pubkey_hex != self.relay_signer_hex { + return false; + } + // ts:344: participantPubkey must be non-null. + if participant.is_empty() { + return false; + } + let session = match self.sessions.get_mut(&session_id) { + Some(s) => s, + None => return false, // ts:334: session must exist + }; + // ts:398-401: update session generation. + if let Some(gen) = content_json["generation"] + .as_str() + .filter(|g| !g.is_empty()) + { + session.0 = Some(gen.to_string()); + } + // ts:370-380: record admission as present. + session.1.insert(participant.to_string(), true); + true + } + + // Reconcile liveness (ts:464-484): + // if stored generation != incoming generation → clear all admissions. + // if equal → leave admissions intact. + // Returns whether admissions were preserved (true) or cleared (false). + fn reconcile_liveness(&mut self, session_id: &str, liveness_generation: &str) -> bool { + let session = match self.sessions.get_mut(session_id) { + Some(s) => s, + None => return false, + }; + let preserved = match &session.0 { + Some(stored) if stored != liveness_generation => { + // ts:464-473: generation mismatch → clear admissions. + session.1.clear(); + false + } + _ => true, // equal or None → admissions preserved. + }; + session.0 = Some(liveness_generation.to_string()); + preserved + } + + // Returns true if the participant has a present admission in the session. + fn is_admitted(&self, session_id: &str, participant_hex: &str) -> bool { + self.sessions + .get(session_id) + .and_then(|(_, admissions)| admissions.get(participant_hex)) + .copied() + .unwrap_or(false) + } + } + + let mut oracle = HuddlePresenceOracle::new(relay_signer_hex.clone()); + + // Seed the session by applying a STARTED event. + // In production, Desktop receives an actual kind-48100 STARTED event. + // Here we directly call apply_started with the session ID (channel_id). + // This mirrors Desktop's session creation path (ts:267-289). + oracle.apply_started(&channel_id.to_string()); + + // Parse the content from the stored 48101 event. + let join_content_json: serde_json::Value = + serde_json::from_str(&join_row.content).unwrap_or_default(); + + // admission_id / roster_revision must be the admission this test drove: + // the member's live room peer id and the room's roster revision. + let room = state + .audio_rooms + .get(community, channel_id) + .expect("F3 wire: room must exist"); + let expected_admission_id = room + .peers + .iter() + .find(|peer| peer.pubkey == expected_participant_hex) + .map(|peer| peer.key().to_string()) + .expect("F3 wire: member must hold a room peer"); + let expected_roster_revision = room.roster_snapshot().revision; + assert_eq!( + join_content_json["admission_id"].as_str(), + Some(expected_admission_id.as_str()), + "F3 wire: JOIN admission_id must be the member's room peer id; got {}", + join_row.content + ); + assert_eq!( + join_content_json["roster_revision"].as_u64(), + Some(expected_roster_revision), + "F3 wire: JOIN roster_revision must be the room revision; got {}", + join_row.content + ); + + // ── Drift guard: real producer output == committed Desktop fixture ─── + // + // Normalize the real JOIN and liveness events (random keys and ids → + // fixture placeholders) and require kind, pubkey, tags and content to + // equal desktop/.../__fixtures__/nip-fi-join-liveness.json, the input + // of the Desktop runtime contract test. + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../desktop/src/features/huddle/lib/__fixtures__/nip-fi-join-liveness.json" + )) + .expect("F3 drift guard: fixture JSON"); + let replacements = [ + ( + relay_signer_hex.clone(), + fixture["relayPubkey"].as_str().unwrap(), + ), + ( + expected_participant_hex.clone(), + fixture["memberPubkey"].as_str().unwrap(), + ), + ( + channel_id.to_string(), + fixture["channelId"].as_str().unwrap(), + ), + ( + expected_generation.clone(), + fixture["generation"].as_str().unwrap(), + ), + ( + expected_admission_id.clone(), + fixture["admissionId"].as_str().unwrap(), + ), + ]; + let normalize = |raw: &str| { + replacements + .iter() + .fold(raw.to_string(), |acc, (from, to)| { + acc.replace(from.as_str(), to) + }) + }; + let shape = |event: &serde_json::Value| { + serde_json::json!({ + "kind": event["kind"], + "pubkey": event["pubkey"], + "tags": event["tags"], + "content": serde_json::from_str::( + event["content"].as_str().unwrap_or("null") + ).unwrap_or_default(), + }) + }; + let produced_join = serde_json::json!({ + "kind": 48101, + "pubkey": join_pubkey_hex, + "tags": join_row.tags_json, + "content": join_row.content, + }); + let produced_liveness = liveness_event + .clone() + .expect("F3 drift guard: liveness EVENT captured"); + for (name, produced) in [("join", &produced_join), ("liveness", &produced_liveness)] { + let normalized: serde_json::Value = + serde_json::from_str(&normalize(&produced.to_string())) + .expect("F3 drift guard: normalized JSON"); + assert_eq!( + shape(&normalized), + shape(&fixture[name]), + "F3 drift guard: relay {name} output diverged from the committed Desktop \ + fixture; update nip-fi-join-liveness.json with the producer change" + ); + } + + assert_eq!( + generation_from_wire, expected_generation, + "F3 wire: 48101 JOIN content.generation from wire bytes ({generation_from_wire}) \ + must equal state.huddle_liveness_generation ({expected_generation}). \ + Both production paths (admission handler + liveness consumer) read the same field. \ + A mismatch here means Desktop reconciliation would clear admissions. \ + Mutation A: change audio/handler.rs:802 to a hardcoded UUID → fails here." + ); + + assert!( + !generation_from_wire.is_empty(), + "F3 wire: 48101 content.generation must be non-empty in wire bytes. \ + Mutation B: remove generation field from commit content → empty string → panics." + ); + + let liveness_gen_value = liveness_gen.expect( + "F3 wire consumer: handle_huddle_liveness_req must return a liveness EVENT. \ + Fixture seeds: valid community, stream channel, live room with peer, \ + KIND_HUDDLE_STARTED link event. A missing EVENT means the liveness handler \ + did not find the room or the link query failed. \ + Mutation A: remove KIND_HUDDLE_STARTED link seed → no EVENT → panics here.", + ); + assert_eq!( + liveness_gen_value, generation_from_wire, + "F3 wire consumer: liveness oracle generation ({liveness_gen_value}) must equal \ + the wire JOIN generation ({generation_from_wire}). \ + Both come from state.huddle_liveness_generation.to_string(). \ + A mismatch here means Desktop would clear Off-mode admissions. \ + Mutation B: change req.rs handle_huddle_liveness_req to use a different UUID \ + → liveness_gen_value != generation_from_wire → panics here." + ); + + // Apply the actual committed JOIN event to the oracle. + // The oracle checks: event.pubkey == relaySelf AND a non-empty participant. + let admitted = oracle.apply_join(&join_pubkey_hex, &participant_hex, &join_content_json); + assert!( + admitted, + "F3 oracle: Desktop-semantics oracle must admit the participant on apply_join. \ + The relay-signed 48101 has: event.pubkey = relay signer (✓), non-empty \ + resolved participant (✓), session exists (seeded above) (✓). \ + An empty p-tag value resolves to an empty participant → apply_join returns \ + false → this assert panics." + ); + + // Reconcile liveness: apply the authoritative liveness generation from the + // relay's handle_huddle_liveness_req oracle (same value as wire generation). + // huddlePresence.ts:464-484: if generation equals stored → admissions preserved. + let admissions_preserved = + oracle.reconcile_liveness(&channel_id.to_string(), &liveness_gen_value); + assert!( + admissions_preserved, + "F3 oracle: reconcile_liveness must preserve the participant admission. \ + Both the JOIN and the liveness oracle use the same generation source \ + (state.huddle_liveness_generation.to_string()), so they are always equal \ + for Off-mode sessions. \ + RED: change req.rs handle_huddle_liveness_req to return a different generation \ + → liveness_gen_value != generation_from_wire (asserted above) → reconcile clears \ + admissions → admissions_preserved = false → panics here." + ); + + // Final assertion: participant is admitted after full chain. + // This is the concrete Desktop admission-retention guarantee: + // real admission → relay-signed JOIN (with p-tag) → oracle admits → + // liveness reconcile with matching generation → admission retained. + assert!( + oracle.is_admitted(&channel_id.to_string(), &participant_hex), + "F3 oracle: participant admission must be retained after liveness reconciliation. \ + Meaning: Desktop will NOT clear this peer when it receives the next liveness event. \ + RED: strip p tag from the committed JOIN → participant not admitted → \ + oracle.is_admitted returns false → panics." + ); + + // Cleanup: release hook and disconnect. + fanout_release.notify_one(); + conn_cancel.cancel(); + server.abort(); + let _ = server.await; + } + + // ── W10: two concurrent committers; expiry during second; first row intact ── + // + // Two concurrent tasks call `commit_participant_join` for different pubkeys. + // Both use the same gate. The first is let through (no hook armed for it). + // The second has the hook armed; expiry fires while it is paused at the hook. + // After release the second rolls back. The first's committed row is intact. + // + // Mutation evidence: + // A) Delete `before_participant_commit(...)` → arrived_rx times out → panic. + // B) Remove `acquire_effect()` from the second path → second commits too → + // two rows present → second-row-count assertion panics. + async fn w10_concurrent_committers_expiry_during_second_first_row_intact_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key_a) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // Second distinct member for the concurrent committer. + let member_key_b = nostr::Keys::generate(); + let member_bytes_b = member_key_b.public_key().to_bytes().to_vec(); + let creator_bytes = member_key_a.public_key().to_bytes().to_vec(); // reuse as invited_by + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(&member_bytes_b) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("W10 fixture: seed second member"); + + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Task A (first committer) — no hook armed; completes without expiry. + let member_bytes_a = member_key_a.public_key().to_bytes().to_vec(); + let member_hex_a = member_key_a.public_key().to_hex(); + let state_a = Arc::clone(&state); + let tenant_a = tenant.clone(); + let gate_a = Arc::clone(&gate); + let handle_a = tokio::spawn(async move { + commit_participant_join( + &state_a, + &tenant_a, + channel_id, + channel_id, + &member_hex_a, + &member_bytes_a, + Uuid::new_v4(), + 1, + "test-generation-a", + &MembershipAdmission::Existing { + parent_channel_id: channel_id, + }, + &gate_a, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant_a.community(), + channel_id, + )), + ) + .await + }); + + // Wait for task A to complete before arming the hook for task B. + let result_a = tokio::time::timeout(std::time::Duration::from_secs(10), handle_a) + .await + .expect("W10: task A must complete within 10s") + .expect("task A must not panic"); + assert!( + result_a.is_ok(), + "W10: task A (first committer) must succeed; got: {result_a:?}" + ); + + // Arm the hook for task B. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_participant_commit_hook::arm(community_id); + + let member_hex_b = member_key_b.public_key().to_hex(); + let state_b = Arc::clone(&state); + let tenant_b = tenant.clone(); + let gate_b = Arc::clone(&gate); + let handle_b = tokio::spawn(async move { + commit_participant_join( + &state_b, + &tenant_b, + channel_id, + channel_id, + &member_hex_b, + &member_bytes_b, + Uuid::new_v4(), + 2, + "test-generation-b", + &MembershipAdmission::Existing { + parent_channel_id: channel_id, + }, + &gate_b, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant_b.community(), + channel_id, + )), + ) + .await + }); + + // Wait for task B to reach the hook. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("W10: task B must reach before_participant_commit within 10s") + .expect("arrived channel closed"); + + // Fire expiry — task B's acquire_effect returns SessionExpired. + cancel.cancel(); + release.notify_one(); + + let result_b = tokio::time::timeout(std::time::Duration::from_secs(10), handle_b) + .await + .expect("W10: task B must return within 10s after hook release") + .expect("task B must not panic"); + + assert!( + matches!(result_b, Err(JoinCommitError::Expired)), + "W10: task B must return JoinCommitError::Expired after mid-flight expiry; got: {result_b:?}" + ); + + // Task A's row persists; task B's row was rolled back. + let row_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("W10: row count query"); + + assert_eq!( + row_count, 1, + "W10: exactly one 48101 row (task A's) must be committed; found {row_count}" + ); + } + + // ── Concurrent-reaffirm variant: same pubkey twice; expiry during second ── + // + // Two concurrent tasks call `commit_participant_join` for the SAME pubkey. + // The second encounters an already-inserted row (idempotent duplicate key → + // `was_inserted = false`), then hits the hook. Expiry fires; the second + // rolls back. The first's row is intact. `JoinCommitError::Expired` is returned + // by the second task. + // + // Contract: expiry during a reaffirm commit rolls back without corrupting the + // first committer's row. The membership row (if Existing) is unaffected. + // + // Mutation evidence: + // A) Delete `before_participant_commit(...)` → arrived_rx times out → panic. + // B) Remove `tx.rollback()` in the Expired branch → second auto-rollback + // still leaves zero new rows (idempotent insert), but `JoinCommitError::Expired` + // assertion still passes — covered by (A) instead. + async fn w10_reaffirm_expiry_during_second_same_pubkey_first_row_intact_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + let member_bytes = member_key.public_key().to_bytes().to_vec(); + let member_hex = member_key.public_key().to_hex(); + + // Both tasks share the same gate (same connection, same pubkey). + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Task 1 (first committer) — completes without expiry. + let state1 = Arc::clone(&state); + let tenant1 = tenant.clone(); + let bytes1 = member_bytes.clone(); + let hex1 = member_hex.clone(); + let gate1 = Arc::clone(&gate); + let handle1 = tokio::spawn(async move { + commit_participant_join( + &state1, + &tenant1, + channel_id, + channel_id, + &hex1, + &bytes1, + Uuid::new_v4(), + 1, + "test-generation", + &MembershipAdmission::Existing { + parent_channel_id: channel_id, + }, + &gate1, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant1.community(), + channel_id, + )), + ) + .await + }); + + let result1 = tokio::time::timeout(std::time::Duration::from_secs(10), handle1) + .await + .expect("reaffirm: task 1 must complete within 10s") + .expect("task 1 must not panic"); + assert!( + result1.is_ok(), + "reaffirm: task 1 (first committer) must succeed; got: {result1:?}" + ); + + // Arm the hook for task 2 (same pubkey — duplicate insert returns was_inserted=false). + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_participant_commit_hook::arm(community_id); + + let state2 = Arc::clone(&state); + let tenant2 = tenant.clone(); + let bytes2 = member_bytes.clone(); + let hex2 = member_hex.clone(); + let gate2 = Arc::clone(&gate); + let handle2 = tokio::spawn(async move { + commit_participant_join( + &state2, + &tenant2, + channel_id, + channel_id, + &hex2, + &bytes2, + Uuid::new_v4(), + 2, + "test-generation", + &MembershipAdmission::Existing { + parent_channel_id: channel_id, + }, + &gate2, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant2.community(), + channel_id, + )), + ) + .await + }); + + // Wait for task 2 to reach the hook (after the duplicate-key 48101 insert). + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("reaffirm: task 2 must reach before_participant_commit within 10s") + .expect("arrived channel closed"); + + // Fire expiry during the reaffirm commit window. + cancel.cancel(); + release.notify_one(); + + let result2 = tokio::time::timeout(std::time::Duration::from_secs(10), handle2) + .await + .expect("reaffirm: task 2 must return within 10s") + .expect("task 2 must not panic"); + + assert!( + matches!(result2, Err(JoinCommitError::Expired)), + "reaffirm: task 2 must return JoinCommitError::Expired; got: {result2:?}" + ); + + // Exactly one committed 48101 row (task 1's). Task 2's transaction rolled back + // (or was a no-op duplicate that rolled back cleanly). + let row_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("reaffirm: row count query"); + + assert_eq!( + row_count, 1, + "reaffirm: exactly one 48101 row (task 1's) must persist; found {row_count}" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CW5: AutoAddRequired path — expiry pre-commit rolls back BOTH rows + // ───────────────────────────────────────────────────────────────────────── + // + // Exercises the `AutoAddRequired` branch of `commit_participant_join` — + // the mechanism introduced by contract correction 2 (e5bc0382). The fixture + // has NO pre-existing membership row, so the auto-add write is attempted + // inside the joint transaction. `before_participant_commit` fires AFTER both + // the membership insert AND the 48101 insert are in the uncommitted + // transaction. Expiry fires at the hook; the acquire_effect check fails; + // the entire transaction rolls back: NEITHER the membership row NOR the + // 48101 row becomes visible. + // + // This is the contract seam that W9 missed: W9 used `Existing` (no auto-add) + // so the membership half of the joint-transaction invariant was never proven. + // + // Mutation evidence (executed): + // CW5A) Delete `before_participant_commit(...)` → arrived_rx times out → panic. + // CW5B) Remove `acquire_effect()` → commit proceeds despite cancel → + // both rows committed → row-count assertions panic. + // CW5C) Change membership_admission to `Existing` → membership path + // never entered; membership row never inserted; this seam not covered. + async fn cw5_auto_add_path_expiry_before_commit_rolls_back_both_rows_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + + // Fixture: community + channel — NO membership row for the test key. + let community_uuid = Uuid::new_v4(); + let host = format!("cw5-test-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("CW5: seed community"); + + let channel_id = Uuid::new_v4(); + let creator = nostr::Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'cw5-test-channel', 'stream', 'open', $3)", + ) + .bind(channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("CW5: seed channel"); + + // The joining pubkey has NO channel_member row — triggers AutoAddRequired. + let joiner_key = nostr::Keys::generate(); + let joiner_bytes = joiner_key.public_key().to_bytes().to_vec(); + let joiner_hex = joiner_key.public_key().to_hex(); + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(community_uuid), + host, + ); + let community_id = tenant.community(); + + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // IMPORTANT 4b requires that the joiner is a member of the parent channel + // before AutoAddRequired can commit. Seed that parent membership now. + // (In production, check_membership_for_admission only returns AutoAddRequired + // if the parent membership exists; the re-read confirms it still does.) + sqlx::query( + "INSERT INTO channel_members (channel_id, community_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(channel_id) + .bind(community_uuid) + .bind(&joiner_bytes) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("CW5: seed parent membership for joiner"); + + // Remove the just-inserted membership so AutoAddRequired still fires + // (we seeded it as the "parent" channel member, but the child channel + // is the same channel_id — so still_absent will now be false and the + // auto-add insert is skipped). We actually want still_absent=true to + // test the auto-add path. To do this properly: use a SEPARATE parent + // channel so the parent membership doesn't conflict with the child check. + // Delete the row we just inserted and use a two-channel fixture. + sqlx::query("DELETE FROM channel_members WHERE channel_id = $1 AND community_id = $2 AND pubkey = $3") + .bind(channel_id) + .bind(community_uuid) + .bind(&joiner_bytes) + .execute(&pool) + .await + .expect("CW5: cleanup parent membership"); + + // Use a two-channel fixture: parent_channel has the joiner as a member; + // child_channel has NO membership for the joiner (triggers AutoAddRequired). + let parent_channel_id = channel_id; // reuse the existing channel as parent + let child_channel_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'cw5-child-channel', 'stream', 'open', $3)", + ) + .bind(child_channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("CW5: seed child channel"); + + // Seed the huddle_started link event (kind 48100) required by the I4 + // re-validation inside commit_participant_join. Links parent_channel_id + // → child_channel_id, signed by creator_bytes. + let huddle_link_content = + serde_json::json!({ "ephemeral_channel_id": child_channel_id.to_string() }).to_string(); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7)", + ) + .bind(community_uuid) + .bind(vec![0xBBu8; 32]) // fixed test event id + .bind(&creator_bytes) + .bind(48100_i32) // KIND_HUDDLE_STARTED + .bind(&huddle_link_content) + .bind(vec![0u8; 64]) // dummy sig (not validated in this path) + .bind(parent_channel_id) + .execute(&pool) + .await + .expect("CW5: seed huddle_started link"); + + // Seed parent membership for the joiner. + sqlx::query( + "INSERT INTO channel_members (channel_id, community_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(parent_channel_id) + .bind(community_uuid) + .bind(&joiner_bytes) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("CW5: seed parent channel membership for joiner"); + + // membership_admission = AutoAddRequired — the joint-tx auto-add path. + // parent_channel_id has the joiner as member (satisfies IMPORTANT 4b re-read). + // child_channel_id has NO membership — so still_absent=true → auto-add fires. + let membership = MembershipAdmission::AutoAddRequired { + parent_channel_id, + channel_created_by: creator_bytes.clone(), + }; + + // Arm the hook: fires between the uncommitted membership+48101 inserts + // and acquire_effect. The full joint transaction is in-flight here. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_participant_commit_hook::arm(community_id); + + let state2 = Arc::clone(&state); + let tenant2 = tenant.clone(); + let joiner_bytes2 = joiner_bytes.clone(); + let joiner_hex2 = joiner_hex.clone(); + let gate2 = Arc::clone(&gate); + let handle = tokio::spawn(async move { + commit_participant_join( + &state2, + &tenant2, + child_channel_id, + parent_channel_id, + &joiner_hex2, + &joiner_bytes2, + Uuid::new_v4(), + 1, + "test-generation", + &membership, + &gate2, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant2.community(), + child_channel_id, + )), + ) + .await + }); + + // Wait for the hook — both membership and 48101 are in the uncommitted tx. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("CW5: commit_participant_join must reach before_participant_commit within 10s") + .expect("arrived channel closed"); + + // Fire expiry — acquire_effect returns SessionExpired; entire tx rolls back. + cancel.cancel(); + release.notify_one(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(10), handle) + .await + .expect("CW5: commit_participant_join must return within 10s after hook release") + .expect("commit_participant_join task must not panic"); + + assert!( + matches!(result, Err(JoinCommitError::Expired)), + "CW5: must return JoinCommitError::Expired after mid-flight expiry; got: {result:?}" + ); + + // Zero 48101 rows — the 48101 insert was rolled back. + let row_count_48101: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_uuid) + .bind(child_channel_id) + .fetch_one(&pool) + .await + .expect("CW5: 48101 row count query"); + + assert_eq!( + row_count_48101, 0, + "CW5: no 48101 row must be committed after AutoAddRequired expiry-rollback; found {row_count_48101}" + ); + + // Zero membership rows for the joiner in the child channel — the auto-add insert was rolled back. + let membership_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_uuid) + .bind(child_channel_id) + .bind(&joiner_bytes) + .fetch_one(&pool) + .await + .expect("CW5: membership row count query"); + + assert_eq!( + membership_count, 0, + "CW5: no membership row must be committed after AutoAddRequired expiry-rollback; found {membership_count}" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CW5-variant: external membership add while paused pre-channel-lock → + // membership preserved; only 48101 commits + // ───────────────────────────────────────────────────────────────────────── + // + // Exercises the concurrent-external-add path in the AutoAddRequired branch + // of `commit_participant_join`. An external transaction inserts the + // membership row while our transaction is paused at `before_membership_lock` + // — just before `acquire_channel_membership_lock_in_transaction`. When our + // transaction resumes: + // 1. It acquires the channel membership lock. + // 2. Re-reads membership — the external insert is committed and visible. + // 3. `still_absent = false` → skips the auto-add insert. + // 4. Inserts 48101 (no duplicate; this pubkey is fresh). + // 5. Acquires the effect permit (no expiry). + // 6. Commits. + // + // Observable invariant: exactly 1 membership row (the external insert) and + // exactly 1 48101 row commit. The join succeeds (Ok), and we did not double- + // insert or corrupt the externally-added membership. + // + // Mutation evidence (executed): + // CW5V-A) Delete `before_membership_lock(...)` → arrived_rx times out → panic. + // CW5V-B) Remove the `still_absent` re-read and always insert → auto-add + // fires → ON CONFLICT DO UPDATE SET role = 'member' clobbers the + // externally-inserted 'admin' role → member.role assertion panics. + // CW5V-C) Remove the `if still_absent { insert }` guard → same as (B). + async fn cw5_variant_concurrent_external_membership_add_preserved_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + + // Fixture: community + channel — NO membership row for the joining key. + let community_uuid = Uuid::new_v4(); + let host = format!("cw5v-test-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("CW5-variant: seed community"); + + let channel_id = Uuid::new_v4(); + let creator = nostr::Keys::generate(); + let creator_bytes = creator.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'cw5v-test-channel', 'stream', 'open', $3)", + ) + .bind(channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("CW5-variant: seed channel"); + + // Seed the huddle_started link event (kind 48100) required by the I4 + // re-validation inside commit_participant_join. The test uses + // parent_channel_id == channel_id (same UUID), so this event needs to + // link channel_id → channel_id from creator_bytes. + let huddle_link_content = + serde_json::json!({ "ephemeral_channel_id": channel_id.to_string() }).to_string(); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7)", + ) + .bind(community_uuid) + .bind(vec![0xAAu8; 32]) // fixed test event id + .bind(&creator_bytes) + .bind(48100_i32) // KIND_HUDDLE_STARTED + .bind(&huddle_link_content) + .bind(vec![0u8; 64]) // dummy sig (not validated in this path) + .bind(channel_id) + .execute(&pool) + .await + .expect("CW5-variant: seed huddle_started link"); + + let joiner_key = nostr::Keys::generate(); + let joiner_bytes = joiner_key.public_key().to_bytes().to_vec(); + let joiner_hex = joiner_key.public_key().to_hex(); + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(community_uuid), + host, + ); + let community_id = tenant.community(); + + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let membership = MembershipAdmission::AutoAddRequired { + parent_channel_id: channel_id, + channel_created_by: creator_bytes.clone(), + }; + + // Arm the pre-lock hook. The join task pauses here before acquiring the + // channel membership lock; while paused, we insert membership externally. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_membership_lock_hook::arm(community_id); + + let state2 = Arc::clone(&state); + let tenant2 = tenant.clone(); + let joiner_bytes2 = joiner_bytes.clone(); + let joiner_hex2 = joiner_hex.clone(); + let gate2 = Arc::clone(&gate); + let pool2 = pool.clone(); + let handle = tokio::spawn(async move { + commit_participant_join( + &state2, + &tenant2, + channel_id, + channel_id, + &joiner_hex2, + &joiner_bytes2, + Uuid::new_v4(), + 1, + "test-generation", + &membership, + &gate2, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant2.community(), + channel_id, + )), + ) + .await + }); + + // Wait for the join task to reach the pre-lock hook. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("CW5-variant: must reach before_membership_lock within 10s") + .expect("arrived channel closed"); + + // External concurrent insert — simulates another legitimate path adding + // the joiner to the channel before our transaction acquires the lock. + // Use role = 'admin' as the distinguishing marker: if auto-add fires, + // `ON CONFLICT DO UPDATE SET role = EXCLUDED.role` (which is 'member') + // clobbers the 'admin' role — the assertion below catches that. + let external_inviter = nostr::Keys::generate(); + let external_inviter_bytes = external_inviter.public_key().to_bytes().to_vec(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'admin', $4)", + ) + .bind(community_uuid) + .bind(channel_id) + .bind(&joiner_bytes) + .bind(&external_inviter_bytes) + .execute(&pool2) + .await + .expect("CW5-variant: external membership insert"); + + // Release the hook — our transaction acquires the lock, re-reads + // (finds existing membership), skips the auto-add, commits only 48101. + release.notify_one(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(10), handle) + .await + .expect("CW5-variant: commit_participant_join must return within 10s") + .expect("commit_participant_join task must not panic"); + + assert!( + result.is_ok(), + "CW5-variant: join must succeed (external add observed, skip insert); got: {result:?}" + ); + + // Verify membership via the normal API: role must be 'admin' (the + // externally-inserted value). If auto-add fires, ON CONFLICT DO UPDATE + // SET role = 'member' clobbers it — this assertion catches that. + let members = + buzz_db::channel_members::get_members(state.db.pool(), community_id, channel_id) + .await + .expect("CW5-variant: get_members query"); + + assert_eq!( + members.len(), + 1, + "CW5-variant: exactly 1 membership row (external's) must persist; found {}", + members.len() + ); + let member = &members[0]; + assert_eq!( + member.pubkey, joiner_bytes, + "CW5-variant: membership row must be for the joiner" + ); + assert_eq!( + member.role, "admin", + "CW5-variant: membership role must be 'admin' (external insert's role preserved — \ + if auto-add fires, ON CONFLICT sets role='member' and this panics)" + ); + + // Exactly 1 committed 48101 row — the join event committed. + let row_count_48101: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_uuid) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW5-variant: 48101 row count query"); + + assert_eq!( + row_count_48101, 1, + "CW5-variant: exactly 1 48101 row (the join event) must commit; found {row_count_48101}" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CW8 (real-DB variant): after_add_peer hook fires → cancel → cleanup runs + // ───────────────────────────────────────────────────────────────────────── + // + // The CW8 contract seam (post-add_peer cleanup) requires a seeded channel + // in the real DB so `check_membership_for_admission` succeeds and the handler + // reaches `room.add_peer`. It requires the PG-wrapper DB; a missing or + // unreachable DB fails. + // + // Mutation evidence (executed): + // CW8A) Delete `after_add_peer(...)` → arrived_rx times out → panic. + // CW8B) Delete `room.remove_peer(peer_id)` from cleanup → room not removed → + // audio_rooms.get() returns Some → room_after.is_none() assertion panics. + // CW8C) Delete `cleanup_if_empty(...)` from cleanup → room entry persists after + // last-peer removal → audio_rooms.get() returns Some → + // room_after.is_none() assertion panics (detects the missing call). + async fn cw8_post_add_peer_cancel_removes_peer_and_cleans_up_real_db_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community = tenant.community(); + + let key = member_key; // Same key is already a member → open path to add_peer. + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let audio_rooms = Arc::clone(&state.audio_rooms); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + let conn_cancel_c = conn_cancel.clone(); + // Save the tenant host before tenant_c is moved into the server closure. + let tenant_host = tenant_c.host().to_string(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + // Arm the after_add_peer hook before the server starts. + let (arrived_rx, release) = crate::nip_fi_test_hooks::audio_add_peer_hook::arm(community); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel_c.clone(); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Complete NIP-42 handshake. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + // Use the tenant's host to build the relay URL — must match the + // nip42_expected_relay_url computed inside handle_active_audio_connection. + let relay_url = format!("ws://{tenant_host}"); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + "parent_channel_id": null, + "protocol_version": 1, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Wait for after_add_peer — peer is now in the room. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("CW8: handler must reach after_add_peer within 5s") + .expect("arrived channel closed"); + + // Fire cancel — simulates expiry arriving at this exact point. + conn_cancel.cancel(); + + // Release hook — handler's explicit cancellation branch runs + // guard.release_before_commit() (remove_peer + cleanup_if_empty). + release.notify_one(); + + // Handler returns (connection closes). + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()).await; + + // Room must be empty AND must have been cleaned up by cleanup_if_empty. + // An empty-but-still-registered room means cleanup_if_empty did NOT fire, + // which would fail the CW8B mutation test (deleting cleanup_if_empty). + // Asserting audio_rooms.get() returns None is the stronger check. + let room_after = audio_rooms.get(community, channel_id); + assert!( + room_after.is_none(), + "CW8: room must have been removed by cleanup_if_empty after post-add_peer cancel; \ + room still present in map (cleanup_if_empty did not fire): peers={:?}", + room_after.as_ref().map(|r| r.peer_pubkeys()) + ); + + // No 48101 committed — commit_participant_join was never reached. + let row_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW8: row count query"); + + assert_eq!( + row_count, 0, + "CW8: no 48101 row must be committed when cancel fires after add_peer; found {row_count}" + ); + + server.abort(); + let _ = server.await; + } + + // ───────────────────────────────────────────────────────────────────────── + // CW10 (contract): expiry queued after commit while permit held → + // fan-out completes; expiry provably blocked at quiescence barrier until + // permit drops + // ───────────────────────────────────────────────────────────────────────── + // + // This is the commit-won/quiescence witness — the heart of the design. + // `after_participant_fanout` fires after tx.commit() AND after fan-out + // (mark_local_event + fan_out_event_to_local_subscribers + publish_event) + // but BEFORE `_permit` drops. + // + // At the hook: arm expiry in a background task. Because `_permit` is still + // held, `gate.expire()` blocks at the write guard. Verify expiry is blocked + // (cancel fires but write guard not yet acquired → expire not complete). + // Release hook → `commit_participant_join` returns → `_permit` drops → + // expiry task acquires write guard → expire() completes. + // + // Observable invariants: + // 1. At hook time: cancel is set (expire called cancel.cancel()) but + // expire() is blocked (write guard not yet acquired). + // 2. After permit drops: expire() completes. + // 3. The 48101 row IS committed (fan-out happened under the permit). + // 4. `local_event_ids` contains the event (mark_local_event ran). + // + // Mutation evidence (executed): + // CW10A) Delete `after_participant_fanout(...)` → arrived_rx times out → panic. + // CW10B) Remove `acquire_effect()` from `commit_participant_join` → the + // permit is never held → expiry is not blocked → expire() completes + // before we check → the "expiry blocked" invariant assertion panics. + // (Note: CW10B is covered by having the expire task complete before + // the hook fires, detectable by checking expire_done before release.) + // CW10C) Move `after_participant_fanout` hook to before `tx.commit()` → + // 48101 not yet committed when hook fires → 48101 row-count assertion + // panics (no row at hook time, but the test checks after completion). + // Actually: the test checks after the whole function returns, so CW10C + // is best evidenced by CW10A (hook placement) + the row-count check. + async fn cw10_expiry_blocked_at_permit_barrier_until_fan_out_completes_body() { + use chrono::{Duration, Utc}; + use std::sync::Arc; + use uuid::Uuid; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + let member_bytes = member_key.public_key().to_bytes().to_vec(); + let member_hex = member_key.public_key().to_hex(); + let peer_id = Uuid::new_v4(); + + // Deadline far in the future — expiry does NOT fire on its own. + let deadline = Utc::now() + Duration::hours(1); + let cancel = tokio_util::sync::CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let membership = MembershipAdmission::Existing { + parent_channel_id: channel_id, + }; + + // Arm the after_participant_fanout hook. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::audio_participant_fanout_hook::arm(community_id); + + let state2 = Arc::clone(&state); + let tenant2 = tenant.clone(); + let bytes2 = member_bytes.clone(); + let hex2 = member_hex.clone(); + let gate2 = Arc::clone(&gate); + let handle = tokio::spawn(async move { + commit_participant_join( + &state2, + &tenant2, + channel_id, + channel_id, + &hex2, + &bytes2, + peer_id, + 1, + "test-generation", + &membership, + &gate2, + String::new(), + &std::sync::Arc::new(crate::audio::room::Room::new( + tenant2.community(), + channel_id, + )), + ) + .await + }); + + // Wait for the hook — tx.commit() ran AND fan-out ran; permit is still held. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("CW10: commit_participant_join must reach after_participant_fanout within 10s") + .expect("arrived channel closed"); + + // 48101 must already be committed (fan-out ran under the permit). + let row_count_at_hook: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW10: row count at hook"); + + assert_eq!( + row_count_at_hook, 1, + "CW10: 48101 row must be committed before the hook fires (fan-out under permit); found {row_count_at_hook}" + ); + + // Arm expiry in a background task. It calls cancel.cancel() immediately + // then blocks at the write guard (because the permit read guard is held). + let gate3 = Arc::clone(&gate); + let expire_done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let expire_done2 = Arc::clone(&expire_done); + let expire_task = tokio::spawn(async move { + gate3.expire(|| {}).await; + expire_done2.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + // Yield a few times so expire_task can start, call cancel.cancel(), and + // reach the write guard (where it blocks). + for _ in 0..10 { + tokio::task::yield_now().await; + } + + // Cancel must be set (expire called cancel.cancel() immediately). + assert!( + cancel.is_cancelled(), + "CW10: cancel must be set when expire() fires" + ); + + // Expiry must NOT have completed yet — permit is still held. + assert!( + !expire_done.load(std::sync::atomic::Ordering::SeqCst), + "CW10: expire() must be blocked at write guard while permit is held" + ); + + // Release hook → `commit_participant_join` returns → `_permit` drops. + release.notify_one(); + + // Wait for the commit_participant_join task to return. + let result = tokio::time::timeout(std::time::Duration::from_secs(10), handle) + .await + .expect("CW10: commit_participant_join must return within 10s after hook release") + .expect("commit_participant_join task must not panic"); + + assert!( + result.is_ok(), + "CW10: commit_participant_join must return Ok after successful commit; got: {result:?}" + ); + + // Wait for the expiry task to complete — now unblocked after permit drop. + tokio::time::timeout(std::time::Duration::from_secs(5), expire_task) + .await + .expect("CW10: expire() task must complete within 5s after permit drop") + .expect("expire task must not panic"); + + assert!( + expire_done.load(std::sync::atomic::Ordering::SeqCst), + "CW10: expire() must complete after permit is dropped" + ); + + // 48101 remains committed — the commit-won invariant holds. + let row_count_final: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW10: final row count query"); + + assert_eq!( + row_count_final, 1, + "CW10: exactly 1 48101 row must persist after commit-won + expiry; found {row_count_final}" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CW10-full-handler: committed join → disconnect → exactly one 48102 + // ───────────────────────────────────────────────────────────────────────── + // + // Full-handler witness (IMPORTANT 5 + teardown): a committed join must + // produce exactly one kind:48101 and exactly one kind:48102, regardless of + // when teardown is triggered. Uses a real DB + full `handle_active_audio_connection` + // invocation so the complete send_loop/recv_loop/forward_loop lifecycle runs. + // + // Steps: + // 1. Seed a channel + member, connect via WS, complete NIP-42 handshake. + // 2. Arm `after_participant_fanout` hook — fires after tx.commit() + fan-out, + // before `_permit` drops. At this point 48101 is committed. + // 3. Release the hook → `commit_participant_join` returns Ok. + // 4. Session enters recv_loop. Immediately cancel `conn_cancel` to + // simulate a client disconnect (or NIP-FI expiry triggering the same + // teardown path). + // 5. Wait for the handler to complete. + // 6. Assert: exactly 1 committed 48101 row; exactly 1 committed 48102 row. + // The pair proves "committed join ⇒ exactly one leave event". + // + // Mutation evidence (executed): + // CW10F-A) Remove the `emit_participant_event(48102, ...)` call from the + // handler epilogue → 48102 count stays 0 → assertion panics. + // CW10F-B) Remove `room.remove_peer(peer_id)` / `remove_peer_and_check_ended` + // from teardown → room is not empty → cleanup_if_empty is a no-op + // → the room entry persists → subsequent get() finds it. + async fn cw10_full_handler_committed_join_produces_exactly_one_leave_event_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community = tenant.community(); + + let key = member_key; + let assertion = VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let audio_rooms = Arc::clone(&state.audio_rooms); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + let conn_cancel_c = conn_cancel.clone(); + let tenant_host = tenant_c.host().to_string(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + // Arm after_participant_fanout: fires when 48101 is committed + fan-out done. + let (fanout_rx, fanout_release) = + crate::nip_fi_test_hooks::audio_participant_fanout_hook::arm(community); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel_c.clone(); + move |ws: axum::extract::ws::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Complete NIP-42 handshake. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge msg") + .expect("challenge ws msg"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + let relay_url = format!("ws://{tenant_host}"); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + let auth_msg = serde_json::json!({ + "type": "auth", + "event": auth_event, + "parent_channel_id": null, + "protocol_version": 1, + }) + .to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Wait for after_participant_fanout — 48101 is committed and fan-out ran. + tokio::time::timeout(std::time::Duration::from_secs(10), fanout_rx) + .await + .expect("CW10-full: handler must reach after_participant_fanout within 10s") + .expect("fanout channel closed"); + + // Verify 48101 is committed before we trigger disconnect. + let row_48101: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48101", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW10-full: 48101 count at hook"); + + assert_eq!( + row_48101, 1, + "CW10-full: 48101 must be committed at after_participant_fanout; found {row_48101}" + ); + + // Release hook → commit_participant_join returns → session enters recv_loop. + fanout_release.notify_one(); + + // Give the session a moment to enter recv_loop before we disconnect. + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Trigger disconnect — cancelling conn_cancel signals the handler's + // cancel token, which causes recv_loop, send_loop, and forward_loop to + // stop; the handler epilogue then calls emit_participant_event(48102, ...). + conn_cancel.cancel(); + + // Handler returns after teardown. Wait for the WS connection to close. + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()).await; + + // Wait a moment for the handler to finish emitting 48102. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Exactly one 48102 row must exist — the "committed join ⇒ exactly one leave" invariant. + let row_48102: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events \ + WHERE community_id = $1 AND channel_id = $2 AND kind = 48102", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("CW10-full: 48102 count"); + + assert_eq!( + row_48102, 1, + "CW10-full: exactly 1 48102 must be committed after a committed join + disconnect; found {row_48102}" + ); + + // Room must be cleaned up. + let room_after = audio_rooms.get(community, channel_id); + assert!( + room_after.is_none(), + "CW10-full: room must be removed after last peer disconnects; \ + room still present: peers={:?}", + room_after.as_ref().map(|r| r.peer_pubkeys()) + ); + + server.abort(); + let _ = server.await; + } + + // ───────────────────────────────────────────────────────────────────────── + // CW6: guard-level witness — unattached lease released on pre-commit exit + // ───────────────────────────────────────────────────────────────────────── + // + // `HuddleAdmissionGuard::release_before_commit` must call `directory.release` + // exactly once when a lease is held and no commit has happened (the guard + // held an unattached lease and was asked to clean up on a pre-commit exit). + // + // This test uses a `CountingDir` (a `HuddleDirectory` double with a release + // counter) injected into the guard's `lease` field. No Redis, no mesh + // transport, no `AppState` required — the guard-level abstraction is the + // seam that makes this feasible without production infrastructure. + // + // The path under test is `HuddleAdmissionGuard::release_before_commit`, which + // calls `directory.release(&lease)` directly and awaits the result. Release + // is guaranteed complete before `release_before_commit` returns — no detached + // renewer task. + // + // Mutation evidence (executed): + // CW6A) Remove `if let Some((lease, directory)) = self.lease.take()` block → + // release is never called → release_calls stays 0 → assertion panics. + #[tokio::test] + async fn cw6_guard_release_before_commit_calls_directory_release_exactly_once() { + use crate::audio::join::{ + AcquireOutcome, HuddleDirectory, HuddleLease, HuddleReleaseOutcome, HuddleRenewOutcome, + Ownership, HUDDLE_CONTROL_PROFILE, + }; + use crate::tunnel::directory::SessionLease; + use buzz_core::CommunityId; + use buzz_relay_mesh::{wire::FencedHeader, MeshError, RuntimeId}; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + // A minimal HuddleDirectory double that counts release calls. + struct CountingDir { + release_calls: Mutex, + } + #[async_trait::async_trait] + impl HuddleDirectory for CountingDir { + async fn owner_of( + &self, + _c: CommunityId, + _s: Uuid, + ) -> Result, MeshError> { + Ok(None) + } + async fn acquire( + &self, + _c: CommunityId, + _s: Uuid, + _owner: RuntimeId, + ) -> Result { + Ok(AcquireOutcome::Acquired(HuddleLease(SessionLease { + community_id: CommunityId::from_uuid(Uuid::nil()), + session_id: Uuid::nil(), + owner_runtime_id: RuntimeId([0u8; 32]), + generation: 1, + profile: HUDDLE_CONTROL_PROFILE, + }))) + } + async fn renew(&self, _lease: &HuddleLease) -> Result { + // Should never be called — the pre-cancelled token hits the + // cancel arm before renew. + Ok(HuddleRenewOutcome::Renewed(HuddleLease(SessionLease { + community_id: CommunityId::from_uuid(Uuid::nil()), + session_id: Uuid::nil(), + owner_runtime_id: RuntimeId([0u8; 32]), + generation: 1, + profile: HUDDLE_CONTROL_PROFILE, + }))) + } + async fn release( + &self, + _lease: &HuddleLease, + ) -> Result { + *self.release_calls.lock().unwrap() += 1; + Ok(HuddleReleaseOutcome::Released) + } + async fn validate( + &self, + _community_id: CommunityId, + _fenced: &FencedHeader, + ) -> Result<(), MeshError> { + Ok(()) + } + } + + let community = CommunityId::from_uuid(Uuid::nil()); + let channel_id = Uuid::new_v4(); + let dir = Arc::new(CountingDir { + release_calls: Mutex::new(0), + }); + + // Build a test HuddleLease (uses pub(crate) inner field — same crate). + let lease = HuddleLease(SessionLease { + community_id: community, + session_id: Uuid::new_v4(), + owner_runtime_id: RuntimeId([0u8; 32]), + generation: 7, + profile: HUDDLE_CONTROL_PROFILE, + }); + + let room = Arc::new(crate::audio::room::Room::new(community, channel_id)); + let audio_rooms = Arc::new(crate::audio::room::AudioRoomManager::default()); + let dir_clone = Arc::clone(&dir) as Arc; + + let mut guard = HuddleAdmissionGuard { + lease: Some((lease, dir_clone)), + remote_session: None, + remote_stream: None, + peer_id: None, + room, + audio_rooms, + community, + channel_id, + }; + + guard.release_before_commit().await; + + // `release_before_commit` now calls `directory.release` directly and + // awaits it — no detached renewer task. Release is complete by the time + // `release_before_commit` returns. + let release_calls = *dir.release_calls.lock().unwrap(); + assert_eq!( + release_calls, 1, + "CW6: directory.release must be called exactly once on pre-commit exit; got {release_calls}" + ); + + // Guard is idempotent — calling release_before_commit again must not + // trigger a second release (lease field is now None). + guard.release_before_commit().await; + let release_calls_after = *dir.release_calls.lock().unwrap(); + assert_eq!( + release_calls_after, 1, + "CW6: second release_before_commit must be idempotent (no double-release); got {release_calls_after}" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // CW7: guard-level witness — clean close sent on remote stream pre-commit exit + // ───────────────────────────────────────────────────────────────────────── + // + // `HuddleAdmissionGuard::release_before_commit` must call `send_clean_close` + // (UnregisterPeer + Goodbye + finish) when a `remote_stream` is held, before + // the guard releases. No real mesh transport, TLS, or remote pod required: + // `MeshStream::new` accepts `Box` stubs, and + // `RemoteHuddleSession::for_test` provides the needed `fenced`/`pubkey`. + // + // Mutation evidence (executed): + // CW7A) Remove `if let (Some(session), Some(ref mut stream)) = ...` block + // in `release_before_commit` → send_frame never called → frames_sent + // stays 0 → assertion panics. + // CW7B) Swap UnregisterPeer and Goodbye order → Goodbye arrives before + // UnregisterPeer → frame[0] is Goodbye, not Data → first frame + // assertion panics (expected Data, got Goodbye). + #[tokio::test] + async fn cw7_guard_release_before_commit_sends_clean_close_on_remote_stream() { + use crate::audio::join::RemoteHuddleSession; + use buzz_relay_mesh::wire::FencedHeader; + use buzz_relay_mesh::RuntimeId; + use buzz_relay_mesh::{ + BoxFuture, MeshError, MeshStream, MeshStreamFrame, StreamRecvHalf, StreamSendHalf, + }; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + // A send half that records every frame sent. + struct RecordingSend { + frames: Arc>>, + finished: Arc>, + } + impl StreamSendHalf for RecordingSend { + fn send_frame( + &mut self, + frame: MeshStreamFrame, + ) -> BoxFuture<'_, Result<(), MeshError>> { + self.frames.lock().unwrap().push(frame); + Box::pin(async { Ok(()) }) + } + fn finish(&mut self) -> Result<(), MeshError> { + *self.finished.lock().unwrap() = true; + Ok(()) + } + } + + // A recv half that always returns None (never read in this test). + struct NullRecv; + impl StreamRecvHalf for NullRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + Box::pin(async { Ok(None) }) + } + } + + let frames = Arc::new(Mutex::new(Vec::::new())); + let finished = Arc::new(Mutex::new(false)); + let stream = MeshStream::new( + Box::new(RecordingSend { + frames: Arc::clone(&frames), + finished: Arc::clone(&finished), + }), + Box::new(NullRecv), + ); + + let community = buzz_core::CommunityId::from_uuid(Uuid::nil()); + let channel_id = Uuid::new_v4(); + let fenced = FencedHeader { + owner_runtime_id: RuntimeId([0u8; 32]), + session_id: Uuid::nil(), + generation: 1, + }; + let pubkey = "test-pubkey-hex".to_string(); + let session = RemoteHuddleSession::for_test(fenced, pubkey.clone()); + + let room = Arc::new(crate::audio::room::Room::new(community, channel_id)); + let audio_rooms = Arc::new(crate::audio::room::AudioRoomManager::default()); + + let mut guard = HuddleAdmissionGuard { + lease: None, + remote_session: Some(session), + remote_stream: Some(stream), + peer_id: None, + room, + audio_rooms, + community, + channel_id, + }; + + guard.release_before_commit().await; + + // Stream must have received UnregisterPeer (Data) then Goodbye, then finish. + let sent = frames.lock().unwrap().clone(); + assert_eq!( + sent.len(), + 2, + "CW7: send_clean_close must send exactly 2 frames (Data + Goodbye); got {}", + sent.len() + ); + + // Frame 0: Data with UnregisterPeer payload — exact pubkey. + match &sent[0] { + MeshStreamFrame::Data { payload, .. } => { + use crate::audio::join::{decode_control, HuddleControlMsg}; + let msg = decode_control(payload) + .expect("CW7: frame[0] Data payload must decode as HuddleControlMsg"); + assert_eq!( + msg, + HuddleControlMsg::UnregisterPeer { + pubkey: pubkey.clone() + }, + "CW7: frame[0] must be UnregisterPeer with exact pubkey; got {msg:?}" + ); + } + other => panic!( + "CW7: frame[0] must be Data (UnregisterPeer), got {other:?} — \ + swap-order mutation: Goodbye before UnregisterPeer" + ), + } + + // Frame 1: Goodbye — order assertion: UnregisterPeer BEFORE Goodbye. + match &sent[1] { + MeshStreamFrame::Goodbye { .. } => {} + other => panic!("CW7: frame[1] must be Goodbye, got {other:?}"), + } + + // Finish must have been called. + assert!( + *finished.lock().unwrap(), + "CW7: send_clean_close must call finish() on the stream" + ); + // remote_session and remote_stream must be cleared. + assert!( + guard.remote_session.is_none(), + "CW7: remote_session must be cleared after release_before_commit" + ); + assert!( + guard.remote_stream.is_none(), + "CW7: remote_stream must be cleared after release_before_commit" + ); + } + + // ── F4: add-peer gate-expired → denial frame delivered before close (handler-bound) ── + // + // Production path: `handle_active_audio_connection`, Step 5, line ~959: + // `audio_gate.acquire_effect()` → Err(SessionExpired) + // → control.expiry_deny_terminal(...) ← PROOF SEAM: must be present + // → lifecycle_cancel + // → R2 drain (while let Ok(msg) = terminal_ctrl_rx.try_recv()) + // → ws_send.send(denial frame) + // → ws_send.send(close frame) + // → return + // + // The test arms `before_add_peer_gate_acquire` to intercept the handler + // just before `audio_gate.acquire_effect()` (Step 5), then cancels the + // connection token (simulating late-firing expiry), then releases the + // hook. `acquire_effect()` immediately returns `SessionExpired`, the + // handler's denial arm runs, and the WS client observes: + // frame 0: Audio restricted JSON payload + // frame 1: 1008 POLICY close + // + // Mutation evidence: + // A) Remove `expiry_deny_terminal` from the add-peer SessionExpired arm → + // no denial frame queued → R2 drain finds empty channel → frame 0 + // is the POLICY close instead of the JSON payload → frame-0 text + // assertion panics. + // B) Remove R2 drain → denial queued but never drained → frame 0 absent + // → timeout panics. + // C) Remove `before_add_peer_gate_acquire` hook call from handler → + // arrived_rx never fires → test panics before any assertion. + // D) Delete `#[cfg(test)] before_add_peer_gate_acquire(...)` line → + // same as C. + // + // Requires Postgres for `seed_audio_fixture` (channel/member row). + async fn f4_add_peer_expired_delivers_denial_before_close_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // VALID future deadline: the already-expired check at handler.rs:475-505 + // fires immediately when the deadline is in the past, routing the handler + // to the wrong arm before the add-peer hook can be reached. Use a + // far-future deadline so the handler proceeds to the target seam, then + // cancel from the test while the hook is held — `acquire_effect()` sees + // `is_cancelled()` and returns `SessionExpired`, exercising the intended + // `expiry_deny_terminal → lifecycle_cancel → finalize_drain!()` arm. + // [F4: valid-deadline / cancel-while-held approach] + let assertion = VerifiedAssertion::for_test( + Some(member_key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let conn_cancel = CancellationToken::new(); + let conn_cancel_for_test = conn_cancel.clone(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + let cancel_c = conn_cancel.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("F4 add-peer: bind test listener"); + let addr = listener.local_addr().expect("F4 add-peer: listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = cancel_c.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let cancel_i = cancel_i.clone(); + let conn_time = chrono::Utc::now(); + let control_i = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_i, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("F4 add-peer: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("F4 add-peer: server ready"); + + // Arm the hook BEFORE connecting so we capture the handler at the exact seam. + let (arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_add_peer_gate_acquire_hook::arm(community_id); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("F4 add-peer: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("F4 add-peer: challenge timeout") + .expect("F4 add-peer: challenge item") + .expect("F4 add-peer: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("F4 add-peer: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("F4 add-peer: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("F4 add-peer: challenge field") + .to_string(); + + // Derive relay URL from fixture tenant — must match nip42_expected_relay_url( + // &state.config.relay_url, &tenant) computed in handle_active_audio_connection. + // hermetic_for_test relay_url uses "ws://" scheme; host is tenant.host(). + let relay_url = format!("ws://{}", tenant.host()); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&member_key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("F4 add-peer: send auth"); + + // Wait for handler to reach before_add_peer_gate_acquire. + // Handler has done: auth → membership check → channel DB reads → step 5 seam. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect("F4 add-peer: handler must reach before_add_peer_gate_acquire within 10s") + .expect("F4 add-peer: hook arrived channel closed"); + + // Cancel from the test while the hook holds the handler. + // The gate uses the same token (cancel_i = conn_cancel) so cancelling here + // causes acquire_effect() to return SessionExpired on hook release. + // This exercises the real caller rejection arm at handler.rs:1096-1124: + // expiry_deny_terminal → lifecycle_cancel → finalize_drain!() + // [F4: cancel-while-held path — exercises the target rejection arm] + conn_cancel_for_test.cancel(); + + // Release the hook: handler resumes → acquire_effect() sees is_cancelled() → + // returns SessionExpired → expiry_deny_terminal enqueues denial frame → + // lifecycle_cancel (transition lock) → finalize_drain! → send frames. + hook_release.notify_one(); + + // Frame 0: Audio authorization-denied JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("F4 add-peer: frame 0 timeout") + .expect("F4 add-peer: frame 0 item") + .expect("F4 add-peer: frame 0 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "F4 add-peer: frame 0 must be exact canonical Audio restricted JSON \ + (proves expiry_deny_terminal ran in the handler's SessionExpired arm)" + ), + other => panic!("F4 add-peer: frame 0 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 1: 1008 POLICY close. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("F4 add-peer: frame 1 timeout") + .expect("F4 add-peer: frame 1 item") + .expect("F4 add-peer: frame 1 message"); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "F4 add-peer: close code must be 1008 POLICY; got {:?}", + cf.code + ); + } + other => panic!("F4 add-peer: frame 1 must be Close(Some(1008)); got {other:?}"), + } + + server.abort(); + let _ = server.await; + } + + // ── F4: commit gate-expired → denial frame delivered before close (handler-bound) ── + // + // Production path: `handle_active_audio_connection`, commit match arm, line ~1306: + // `commit_participant_join(...)` → Err(JoinCommitError::Expired) + // → control.expiry_deny_terminal(...) ← PROOF SEAM: must be present + // → lifecycle_cancel + // → R2 drain (while let Ok(msg) = terminal_ctrl_rx.try_recv()) + // → ws_send.send(denial frame) + // → ws_send.send(close frame) + // → return + // + // Uses `before_participant_commit` (already in `nip_fi_test_hooks`) which fires + // inside `commit_participant_join` BEFORE `acquire_effect()`. Test arms the hook, + // waits for arrival (handler at the commit gate seam), cancels from test side, + // releases hook → `commit_participant_join` returns `Err(JoinCommitError::Expired)` + // → handler's Expired arm runs → wire delivers denial + close. + // + // Mutation evidence: + // A) Remove `expiry_deny_terminal` from the JoinCommitError::Expired arm → + // no denial frame queued → R2 drain finds empty channel → frame 0 is + // the POLICY close → frame-0 text assertion panics. + // B) Remove R2 drain → denial queued but never drained → timeout panics. + // C) Delete `before_participant_commit(...)` call from commit_participant_join → + // arrived_rx never fires → test panics. + // + // Requires Postgres for DB writes in the commit path. + async fn f4_commit_expired_delivers_denial_before_close_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // VALID future deadline: same rationale as F4 add-peer — an already-expired + // deadline is rejected at handler.rs:475-505 before the commit hook is reachable. + // Use a far-future deadline so the handler reaches the commit seam, then cancel + // from the test while the hook is held — acquire_effect() sees is_cancelled() + // and returns SessionExpired → JoinCommitError::Expired → handler's Expired arm. + // [F4: valid-deadline / cancel-while-held approach] + let assertion = VerifiedAssertion::for_test( + Some(member_key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let conn_cancel = CancellationToken::new(); + let conn_cancel_for_test = conn_cancel.clone(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + let cancel_c = conn_cancel.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("F4 commit: bind test listener"); + let addr = listener.local_addr().expect("F4 commit: listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = cancel_c.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let cancel_i = cancel_i.clone(); + let conn_time = chrono::Utc::now(); + let control_i = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_i, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("F4 commit: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("F4 commit: server ready"); + + // Arm `before_participant_commit`: fires inside commit_participant_join + // BEFORE acquire_effect(). This intercepts the handler at the commit seam. + let (arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_participant_commit_hook::arm(community_id); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("F4 commit: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("F4 commit: challenge timeout") + .expect("F4 commit: challenge item") + .expect("F4 commit: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("F4 commit: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("F4 commit: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("F4 commit: challenge field") + .to_string(); + + // Derive relay URL from fixture tenant — must match nip42_expected_relay_url( + // &state.config.relay_url, &tenant) computed in handle_active_audio_connection. + let relay_url = format!("ws://{}", tenant.host()); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&member_key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("F4 commit: send auth"); + + // Wait for handler to reach before_participant_commit. + // Handler has done: auth → membership → add_peer → 48101 insert → commit seam. + tokio::time::timeout(std::time::Duration::from_secs(15), arrived_rx) + .await + .expect("F4 commit: handler must reach before_participant_commit within 15s") + .expect("F4 commit: hook arrived channel closed"); + + // Cancel from the test while the hook holds the handler at the commit seam. + // The gate uses the same token (cancel_c = conn_cancel) so cancelling here + // causes acquire_effect() in commit_participant_join to return SessionExpired. + // → JoinCommitError::Expired → handler's Expired arm: + // expiry_deny_terminal → lifecycle_cancel → finalize_drain! → send frames. + // [F4: cancel-while-held path — exercises JoinCommitError::Expired arm] + conn_cancel_for_test.cancel(); + + // Release hook: commit_participant_join resumes → acquire_effect() sees + // is_cancelled() → Err(JoinCommitError::Expired) → denial + close frames. + hook_release.notify_one(); + + // Frame 0: Audio authorization-denied JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("F4 commit: frame 0 timeout") + .expect("F4 commit: frame 0 item") + .expect("F4 commit: frame 0 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "F4 commit: frame 0 must be exact canonical Audio restricted JSON \ + (proves expiry_deny_terminal ran in the handler's JoinCommitError::Expired arm)" + ), + other => panic!("F4 commit: frame 0 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 1: 1008 POLICY close. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("F4 commit: frame 1 timeout") + .expect("F4 commit: frame 1 item") + .expect("F4 commit: frame 1 message"); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "F4 commit: close code must be 1008 POLICY; got {:?}", + cf.code + ); + } + other => panic!("F4 commit: frame 1 must be Close(Some(1008)); got {other:?}"), + } + + server.abort(); + let _ = server.await; + } + + // ── F4: elapsed-deadline arm — expiry fires while handler is at add-peer hook ── + // + // Production path: `nip_fi_gate.rs:139-142` — the ELAPSED-DEADLINE rejection in + // `acquire_effect()`: + // if let Some(deadline) = self.deadline { + // if Utc::now() >= deadline { + // return Err(SessionExpired); ← this arm (not 136-137 cancel arm) + // + // This test differs from `f4_add_peer_expired_delivers_denial_before_close`: + // - THAT test: far-future deadline, test calls `cancel.cancel()` while hook held. + // Exercises the CANCELLATION arm (nip_fi_gate.rs:136-137). + // - THIS test: near-future deadline (2s), waits for the real tokio::time::sleep + // to fire in the expiry task. Hooks the expiry task BEFORE gate.expire() + // (cancel NOT yet set), then releases the add-peer hook so acquire_effect() + // returns SessionExpired via the ELAPSED-DEADLINE arm (139-142). + // + // Two-hook sequence: + // 1. Arm `before_add_peer_gate_acquire` (handler hook at Step 5 seam). + // 2. Arm `expiry_pre_expire_hook` (expiry task hook after sleep, before expire). + // 3. Connect + auth → handler reaches add-peer hook; expiry task sleeping. + // 4. Await `before_add_peer_gate_acquire` arrival (handler paused at Step 5). + // 5. Await `expiry_pre_expire_hook` arrival (sleep fired, cancel NOT set). + // 6. Assert: cancel is NOT cancelled (gate.expire() has not yet fired cancel.cancel()). + // 7. Release `before_add_peer_gate_acquire` → acquire_effect() reads the read guard, + // checks cancel (false), checks deadline (Utc::now() >= deadline → true) → + // returns Err(SessionExpired) via the deadline arm (nip_fi_gate.rs:139-142). + // 8. Release `expiry_pre_expire_hook` → expiry task calls gate.expire() → cancel set. + // 9. Client receives: denial JSON + 1008 POLICY close. + // + // Mutation evidence: + // A) Delete `expiry_pre_expire_hook::fire(...)` call from nip_fi_session.rs → + // expiry_arrived_rx times out → test panics (proves hook at correct seam). + // B) Flip deadline to `Utc::now() + Duration::hours(1)` → + // expiry_arrived_rx never fires within 5s → test panics + // (proves the sleep must actually fire, not just cancel). + // C) Remove `expiry_deny_terminal` from the SessionExpired arm → + // no denial frame queued → frame 0 is the close → text assertion panics. + // + // Requires Postgres for `seed_audio_fixture` (channel/member row). + async fn f4_elapsed_deadline_arm_fires_on_gate_acquire_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // Near-future deadline: 2 seconds from now. + // MUST be in the future (not past) so the handler passes the already-expired + // check at handler.rs:475-505 and reaches the add-peer hook at Step 5. + // The expiry task's tokio::time::sleep(remaining) fires after ~2s of real + // wall-clock time, which is enough for auth + membership + DB reads to complete. + let deadline = Utc::now() + Duration::milliseconds(2000); + let assertion = VerifiedAssertion::for_test(Some(member_key.public_key()), vec![deadline]); + + // Create the control OUTSIDE the server closure so the test has access to + // `conn_control.hook_key` for arming the expiry_pre_expire_hook. + // Both the server closure and the test hold Clones of the same control. + let conn_cancel = CancellationToken::new(); + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_control_for_server = conn_control.clone(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("F4 elapsed: bind test listener"); + let addr = listener.local_addr().expect("F4 elapsed: listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_i = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_i = control_i.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_i, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("F4 elapsed: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("F4 elapsed: server ready"); + + // Arm BOTH hooks before connecting. + // + // `before_add_peer_gate_acquire`: fires in the handler's Step 5, just before + // audio_gate.acquire_effect(). Pauses the handler at the gate seam. + // + // `expiry_pre_expire_hook`: fires in spawn_nip_fi_expiry_task's sleep arm, + // AFTER the real tokio::time::sleep fires (deadline elapsed) but BEFORE + // gate.expire() is called (cancel NOT yet set). + let (add_peer_arrived_rx, add_peer_release) = + crate::nip_fi_test_hooks::audio_add_peer_gate_acquire_hook::arm(community_id); + let (expiry_arrived_rx, expiry_release) = + crate::nip_fi_session::expiry_pre_expire_hook::arm(conn_control.hook_key); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("F4 elapsed: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("F4 elapsed: challenge timeout") + .expect("F4 elapsed: challenge item") + .expect("F4 elapsed: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("F4 elapsed: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("F4 elapsed: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("F4 elapsed: challenge field") + .to_string(); + + let relay_url = format!("ws://{}", tenant.host()); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&member_key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("F4 elapsed: send auth"); + + // Step 4: wait for handler to reach before_add_peer_gate_acquire. + // The handler has done: auth → membership check → channel DB reads → Step 5 seam. + // Allow up to 10s for the DB-heavy auth path to complete. + tokio::time::timeout(std::time::Duration::from_secs(10), add_peer_arrived_rx) + .await + .expect("F4 elapsed: handler must reach before_add_peer_gate_acquire within 10s") + .expect("F4 elapsed: add_peer arrived channel closed"); + + // Step 5: wait for the expiry task's sleep to fire and the task to pause at + // expiry_pre_expire_hook. The deadline is 2s from test start; allow 5s + // from this point (handler auth may have taken up to ~1s already). + // + // At the moment this await returns: + // - tokio::time::sleep(remaining) has completed → wall-clock is past deadline + // - gate.expire() has NOT been called → cancel.is_cancelled() == false + tokio::time::timeout(std::time::Duration::from_secs(5), expiry_arrived_rx) + .await + .expect( + "F4 elapsed: expiry task must reach pre-expire hook within 5s \ + (deadline is 2s; if this times out, the sleep did not fire)", + ) + .expect("F4 elapsed: expiry_arrived channel closed"); + + // Step 6: assert cancel is NOT yet set — the hook fired BEFORE gate.expire(). + // This is the critical precondition: acquire_effect() must return SessionExpired + // via the elapsed-deadline arm (nip_fi_gate.rs:139-142), not the cancel arm + // (136-137). + assert!( + !conn_cancel.is_cancelled(), + "F4 elapsed: cancel must NOT be set at expiry_pre_expire_hook arrival \ + (hook fires before gate.expire(), which calls cancel.cancel())" + ); + + // Step 7: release the add-peer hook. + // The handler resumes, calls acquire_effect(): + // - read guard acquired (expiry task hasn't called gate.expire() yet → no write guard) + // - cancel check: is_cancelled() == false → passes + // - deadline check: Utc::now() >= deadline → true → returns Err(SessionExpired) + // via the elapsed-deadline arm (nip_fi_gate.rs:139-142) + // Handler's SessionExpired arm runs: + // expiry_deny_terminal → lifecycle_cancel → await expiry_task → finalize_drain! + add_peer_release.notify_one(); + + // Step 7b: wait for the handler's SessionExpired arm to reach lifecycle_cancel. + // lifecycle_cancel() → conn_cancel.cancel() is the observable signal that: + // (a) acquire_effect() returned Err(SessionExpired) via the elapsed-deadline arm, + // (b) the handler called expiry_deny_terminal (denial frame enqueued), and + // (c) the handler is now awaiting the expiry task. + // + // Only AFTER this observation do we release the expiry hook. Releasing expiry + // before the handler has entered its SessionExpired arm would create a window + // where gate.expire() runs before the handler even calls acquire_effect(), so + // the deadline arm might never fire and the test could pass vacuously. + // + // RED gate: remove the elapsed-deadline check at nip_fi_gate.rs:~139-142. + // Without it, acquire_effect() returns Ok(permit) (cancel is false, no deadline check) + // → handler never hits SessionExpired → lifecycle_cancel never fires → + // conn_cancel never becomes true → this timeout fires → test FAILS deterministically. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if conn_cancel.is_cancelled() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect( + "F4 elapsed: handler must call lifecycle_cancel() (conn_cancel becomes true) \ + within 5s after add-peer release. \ + This proves acquire_effect() returned Err(SessionExpired) via the elapsed-deadline \ + arm at nip_fi_gate.rs:~139-142 and the handler's rejection arm ran. \ + RED gate: remove the elapsed-deadline check → acquire_effect() returns Ok → \ + handler never enters SessionExpired → lifecycle_cancel never fires → times out.", + ); + + // Step 8: release the expiry hook so gate.expire() can complete. + // The handler is now blocked on `_nip_fi_admission_expiry.take().unwrap().await`. + // Releasing the expiry hook lets gate.expire() run → write guard acquired → + // phase = Expired → task returns → handler's await completes → finalize_drain! + // sends the denial frame and close frame. + expiry_release.notify_one(); + + // Frame 0: Audio authorization-denied JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("F4 elapsed: frame 0 timeout") + .expect("F4 elapsed: frame 0 item") + .expect("F4 elapsed: frame 0 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "F4 elapsed: frame 0 must be exact canonical Audio restricted JSON \ + (proves expiry_deny_terminal ran in the handler's SessionExpired arm \ + via the elapsed-deadline path)" + ), + other => panic!("F4 elapsed: frame 0 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 1: 1008 POLICY close. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("F4 elapsed: frame 1 timeout") + .expect("F4 elapsed: frame 1 item") + .expect("F4 elapsed: frame 1 message"); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "F4 elapsed: close code must be 1008 POLICY; got {:?}", + cf.code + ); + } + other => panic!("F4 elapsed: frame 1 must be Close(Some(1008)); got {other:?}"), + } + + server.abort(); + let _ = server.await; + } + + // ── R2: drain_terminal!() serialization race witness ───────────────────────── + // + // Exercises the `lifecycle_cancel()` call INSIDE `drain_terminal!()`. This is + // the key serialization step: when the handler calls `drain_terminal!()`, it must + // acquire the transition lock (via `lifecycle_cancel()`) before draining, so that + // a concurrent admin producer that has won the reason but not yet called `try_send` + // is forced to complete its enqueue before the drain runs. + // + // IMPORTANT — no assertion (None): the handler is called with nip_fi_assertion=None + // so audio_session_deadline=None → no expiry task spawned → _nip_fi_admission_expiry=None + // → drain_terminal!()'s `t.await` branch is a no-op. This isolates the falsification: + // lifecycle_cancel() is the SOLE serialization. If it were removed, the drain would + // see an empty channel (admin hasn't called try_send yet) and deliver no denial frame. + // With a far-future deadline, `t.await` would wait for cancel (fired after try_send), + // accidentally serializing the drain even without lifecycle_cancel() — making the + // falsification target invisible. + // + // Production path: `handle_active_audio_connection`, check_membership_for_admission + // Err arm (non-member key), which calls `drain_terminal!()` directly: + // 1. auth + relay membership → pass + // 2. check_membership_for_admission → Err("not a member") + // 3. ws_send.send("not a member" diagnostic frame) + // 4. [before_not_a_member_drain_terminal hook fires — handler paused] + // 5. Admin fires disconnect_nip_fi → wins reason → cancel_race_test_hook pauses + // (transition lock held, before try_send). + // 6. hook_ready signal → test confirms admin is paused with lock held. + // 7. Release before_not_a_member_drain_terminal hook → handler proceeds to + // drain_terminal!() → lifecycle_cancel() tries to acquire transition lock → + // BLOCKS (admin holds it). + // 8. Test releases cancel_race_test_hook → admin completes try_send → drops lock. + // 9. lifecycle_cancel() unblocks → acquires lock → publishes LifecycleClosed → + // drops lock. + // 10. drain_terminal!(): t.await no-op (None expiry) → try_recv() → denial frame → + // ws_send → 1008 POLICY close. + // + // The WS client observes: + // frame 0: "not a member" diagnostic JSON + // frame 1: Audio restricted JSON (denial frame from admin) + // frame 2: 1008 POLICY close + // + // Falsification target: remove `lifecycle_cancel()` from `drain_terminal!()`. + // Without the lock acquisition, the handler drains immediately while admin + // is still paused at cancel_race_test_hook (before try_send). The channel + // is empty → denial frame absent → only diagnostic + close → frame-1 timeout + // → test panics. This is the mutation that Thufir's review identified: + // "remove only the new lifecycle_cancel() from drain_terminal!() → test RED." + // + // Mutation evidence: + // A) Delete `before_not_a_member_drain_terminal(...)` from handler → + // `arrived_rx` times out → test panics (proves hook is at correct seam). + // B) Remove `lifecycle_cancel()` from `drain_terminal!()` macro → + // handler drains without blocking → admin's try_send races → denial absent + // → frame-1 recv times out → panics. + // C) Remove R2 drain (`while let Ok(msg) = terminal_ctrl_rx.try_recv()`) from + // `drain_terminal!()` → denial queued but never consumed → frame-1 timeout. + // + // Requires Postgres for `seed_audio_fixture` (channel/member row for membership). + // Uses a NON-MEMBER key (fresh generate()) so check_membership_for_admission fails. + async fn r2_drain_terminal_lifecycle_cancel_serializes_admin_frame_body() { + use std::sync::Arc; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, _member_key) = seed_private_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // Use a NON-MEMBER key: passes relay membership (require_relay_membership=false) + // but fails check_membership_for_admission → triggers the "not a member" arm → + // before_not_a_member_drain_terminal hook → drain_terminal!(). + let non_member_key = nostr::Keys::generate(); + + // No NIP-FI assertion (pass None to handler): audio_session_deadline=None, + // so NO expiry task is spawned → _nip_fi_admission_expiry=None → + // drain_terminal!()'s `t.await` branch is skipped entirely → + // lifecycle_cancel() is the ONLY serialization protecting against admin's race. + // + // With a far-future deadline, `t.await` would wait for cancel (fired only + // after admin's try_send), incidentally serializing the drain even without + // lifecycle_cancel(). That makes the lifecycle_cancel() falsification + // target invisible. By omitting the assertion, we isolate the mechanism: + // GREEN: lifecycle_cancel() blocks on admin's lock → admin proceeds → + // try_send → lock dropped → lifecycle_cancel unblocks → drain sees + // the denial frame. + // RED (mutation B): no lifecycle_cancel() → t.await no-op → drain sees + // empty channel (admin still at hook) → no denial frame → timeout. + + // Pre-create and register the control so disconnect_nip_fi can find the session. + // audio_post_auth_register writes proven_pubkey on this same Arc after NIP-42 auth. + let conn_cancel = tokio_util::sync::CancellationToken::new(); + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = uuid::Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community_id, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("R2 drain: bind test listener"); + let addr = listener.local_addr().expect("R2 drain: listener addr"); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: axum::extract::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + None, // No assertion: no expiry task spawned; + // lifecycle_cancel() is the sole serializer. + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("R2 drain: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("R2 drain: server ready"); + + // ── Arm cancel_race_test_hook BEFORE connecting ─────────────────── + // The hook fires inside disconnect_nip_fi after winning the reason, + // while the transition lock is still held. We arm it before the + // client connects so it's ready by the time the test fires the admin. + let hook_key = conn_control.hook_key; + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + + // Drop guard: disarms cancel_race_test_hook on panic/exit. + struct CancelHookGuard(uuid::Uuid); + impl Drop for CancelHookGuard { + fn drop(&mut self) { + crate::state::cancel_race_test_hook::disarm(self.0); + } + } + let _cancel_hook_guard = CancelHookGuard(hook_key); + + crate::state::cancel_race_test_hook::arm( + hook_key, + Arc::new(move || { + // Signal the test: admin has won reason, lock is held (before try_send). + hook_ready_tx.send(()).unwrap(); + // Hold the lock until the test releases — this is what causes + // lifecycle_cancel() in drain_terminal!() to BLOCK. + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "R2 drain cancel_race_test_hook: test did not send proceed within 5 s — \ + handler or test deadlocked", + ); + }), + ); + + // ── Arm before_not_a_member_drain_terminal BEFORE connecting ───── + let (handler_arrived_rx, handler_release) = + crate::nip_fi_test_hooks::audio_not_a_member_drain_terminal_hook::arm(community_id); + + let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/")) + .await + .expect("R2 drain: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("R2 drain: challenge timeout") + .expect("R2 drain: challenge item") + .expect("R2 drain: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("R2 drain: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("R2 drain: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("R2 drain: challenge field") + .to_string(); + + let relay_url = format!("ws://{}", tenant.host()); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&non_member_key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("R2 drain: send auth"); + + // ── Wait for handler to reach before_not_a_member_drain_terminal ── + // Handler has done: challenge → auth → relay membership → membership check Err. + tokio::time::timeout(std::time::Duration::from_secs(10), handler_arrived_rx) + .await + .expect( + "R2 drain: handler must reach before_not_a_member_drain_terminal within 10s \ + (proves hook is at the correct seam in the not-a-member arm)", + ) + .expect("R2 drain: hook arrived channel closed"); + + // Frame 0: "not a member" diagnostic JSON (sent before the hook). + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("R2 drain: frame 0 timeout") + .expect("R2 drain: frame 0 item") + .expect("R2 drain: frame 0 message"); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert!( + t.contains("not a member"), + "R2 drain: frame 0 must be the 'not a member' diagnostic; got: {t}" + ); + } + other => panic!("R2 drain: frame 0 must be Text(not a member); got {other:?}"), + } + + // ── Fire admin disconnect in a thread ───────────────────────────── + // disconnect_nip_fi runs in a sync thread (holds std::sync::Mutex). + // The cancel_race_test_hook pauses it after winning the reason but + // BEFORE try_send, while holding the transition lock. + let non_member_pubkey = non_member_key.public_key().to_bytes().to_vec(); + // The session was admitted without an assertion (see setup), so it has + // no NIP-FI issuer and no issuer-scoped scan can match it. Stamp an + // issuer on the already-registered identity (handler is parked at the + // drain seam) so the admin scan reaches this control. + conn_control.set_proven_identity(non_member_pubkey.clone(), Some("test-issuer".to_owned())); + let state_for_deny = Arc::clone(&state); + let deny_thread = std::thread::spawn(move || { + state_for_deny + .community_connections + .disconnect_nip_fi("test-issuer", &non_member_pubkey) + }); + + // Wait for admin to reach the hook (won reason, lock held). + // Only now is the invariant established: admin holds the lock + handler + // is at the drain_terminal!() seam → releasing the handler will cause + // lifecycle_cancel() to block. + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "R2 drain: cancel_race_test_hook never fired within 5 s — \ + disconnect_nip_fi hook path never reached (check pubkey registration)", + ); + + // ── Release handler hook, then release admin from a background thread ─ + // The handler proceeds to drain_terminal!() → lifecycle_cancel() → tries to + // acquire the transition lock → BLOCKS (admin holds it). + // + // We CANNOT yield between releasing the handler and releasing admin when running + // on a single-threaded tokio runtime (#[tokio::test]): the handler's synchronous + // Mutex::lock() inside lifecycle_cancel() blocks the tokio worker thread once the + // handler task is scheduled, making it impossible for the test task to ever send + // the proceed signal after yielding. + // + // Fix: fire the proceed signal from a separate OS thread with a small delay. + // This allows the sequence: + // 1. handler_release.notify_one() — handler is runnable + // 2. OS thread spawned to send proceed after 10 ms + // 3. test awaits frame 1 → yields to runtime + // 4. handler task runs → lifecycle_cancel() → blocks on mutex (admin holds it) + // 5. OS thread (10 ms later) → sends proceed → admin completes try_send → drops lock + // 6. handler acquires lock → lifecycle_cancel completes → drain → gets denial frame + // + // Falsification target B: remove lifecycle_cancel() from drain_terminal!() → + // handler does NOT block on mutex → drains immediately in step 4 → + // channel is EMPTY (admin hasn't sent yet, 10 ms timer not fired) → + // try_recv returns Err(Empty) → no denial frame → frame 1 is POLICY close → + // assertion below panics. + handler_release.notify_one(); + // Release admin from a background thread to avoid blocking the tokio worker. + std::thread::spawn(move || { + // Small pause: lets the handler task be scheduled and start lifecycle_cancel(). + // Even if the handler races ahead (no lifecycle_cancel), it will drain an + // EMPTY channel (admin hasn't sent yet) → no denial frame → mutation RED. + std::thread::sleep(std::time::Duration::from_millis(20)); + let _ = hook_proceed_tx.send(()); + }); + + // ── Await denial frame: proves lifecycle_cancel() blocked + admin enqueued ─ + // Frame 1: Audio restricted JSON (denial frame from admin's try_send). + // This frame proves that drain_terminal!()'s lifecycle_cancel() blocked + // on admin's lock and waited for try_send to complete before draining. + // Without lifecycle_cancel() in drain_terminal!(), the drain runs before + // admin's try_send → channel empty → this timeout fires → test RED. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect( + "R2 drain: frame 1 (denial) timeout — drain_terminal!() must deliver \ + the admin denial frame before close. \ + Mutation B: remove lifecycle_cancel() from drain_terminal!() → \ + drain races admin's try_send → channel empty → this times out → RED.", + ) + .expect("R2 drain: frame 1 item") + .expect("R2 drain: frame 1 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "R2 drain: frame 1 must be the Audio restricted JSON denial frame \ + (proves drain_terminal!() serialized after admin's try_send). \ + Mutation A: remove R2 drain from drain_terminal!() → this times out." + ), + other => panic!("R2 drain: frame 1 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 2: 1008 POLICY close. + let frame2 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("R2 drain: frame 2 timeout") + .expect("R2 drain: frame 2 item") + .expect("R2 drain: frame 2 message"); + match &frame2 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "R2 drain: close code must be 1008 POLICY; got {:?}", + cf.code + ); + } + other => panic!("R2 drain: frame 2 must be Close(Some(1008 POLICY)); got {other:?}"), + } + + let closed = deny_thread.join().expect("R2 drain: deny thread panicked"); + assert_eq!( + closed, 1, + "R2 drain: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + server.abort(); + let _ = server.await; + } + + // ── R2: room-ended admission error + concurrent admin deny → denial delivered ── + // + // Production path: `handle_active_audio_connection`, AdmissionError::Ended arm: + // 1. acquire_effect() → Ok (permit acquired) + // 2. add_peer() → Err(Ended) ← room pre-marked ended + // 3. ws_send.send(room_ended error frame) + // 4. [R2 hook fires here — admin fires denial] + // 5. control.lifecycle_cancel() ← loses reason (admin won) + // 6. R2 drain: terminal_ctrl_rx.try_recv() → denial frame → ws_send + // 7. ws_send.send(reason.close_message()) ← 1008 POLICY close + // + // The WS client observes: + // frame 0: room_ended error JSON + // frame 1: Audio restricted JSON (denial frame queued by admin in step 4) + // frame 2: 1008 POLICY close + // + // Mutation evidence: + // A) Remove R2 drain (`while let Ok(msg) = terminal_ctrl_rx.try_recv()`) → + // admin denial is queued but never consumed → client never sees frame 1 → + // timeout on frame-1 recv → test panics. + // B) Delete `before_room_ended_lifecycle_cancel(...)` from handler → + // `arrived_rx` times out → test panics (proves hook is at the right seam). + // C) Remove `set_terminal_frame_sender` call from handler → admin's + // `disconnect_nip_fi` finds no sender → nothing enqueued → frame 1 absent → + // panics. + // + // Requires Postgres for `seed_audio_fixture` (channel/member row for membership check). + async fn r2_room_ended_concurrent_admin_delivers_denial_before_close_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + use tokio_tungstenite::connect_async; + + let state = audio_test_state_real_db().await; + let pool = state.db.pool().clone(); + let (tenant, channel_id, member_key) = seed_audio_fixture(&pool).await; + let community_id = tenant.community(); + + // Far-future deadline: expiry task is armed but does NOT fire during this test. + // The denial comes from the concurrent admin path, not expiry. + let assertion = VerifiedAssertion::for_test( + Some(member_key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let pubkey_bytes = member_key.public_key().to_bytes().to_vec(); + + // Pre-mark the room as ended so add_peer returns AdmissionError::Ended. + // The room must exist in audio_rooms BEFORE the handler calls get_or_create; + // calling get_or_create and then marking it ended is equivalent. + let room = state.audio_rooms.get_or_create(community_id, channel_id); + room.mark_ended(); + + // Pre-create and register the control so state.community_connections can + // find this audio session by pubkey for the admin disconnect scan. + // audio_post_auth_register writes proven_pubkey on this same Arc after + // NIP-42 auth; the registered entry is updated in-place. + let conn_cancel = tokio_util::sync::CancellationToken::new(); + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community_id, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("R2: bind test listener"); + let addr = listener.local_addr().expect("R2: listener addr"); + + let server = tokio::spawn(async move { + let app = axum::Router::new().route( + "/", + axum::routing::get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: axum::extract::WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + channel_id, + control_inner, + Some(assertion_i), + conn_time, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("R2: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("R2: server ready"); + + // Arm the R2 hook BEFORE connecting so we capture the handler at the + // Ended error seam (after room_ended error sent, before lifecycle_cancel). + let (arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_room_ended_lifecycle_cancel_hook::arm(community_id); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("R2: connect client"); + + // NIP-42 auth exchange. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("R2: challenge timeout") + .expect("R2: challenge item") + .expect("R2: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("R2: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("R2: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("R2: challenge field") + .to_string(); + + // Derive relay URL from fixture tenant — must match nip42_expected_relay_url( + // &state.config.relay_url, &tenant) computed in handle_active_audio_connection. + let relay_url = format!("ws://{}", tenant.host()); + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", &relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&member_key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("R2: send auth"); + + // Wait for handler to reach before_room_ended_lifecycle_cancel. + // Handler has done: auth → membership → channel reads → add_peer (Ended) → + // room_ended error frame sent → HOOK fires. + tokio::time::timeout(std::time::Duration::from_secs(10), arrived_rx) + .await + .expect( + "R2: handler must reach before_room_ended_lifecycle_cancel within 10s \ + (proves hook is at the correct seam in the Ended arm)", + ) + .expect("R2: hook arrived channel closed"); + + // Frame 0: room_ended error JSON (sent before the hook, so available now). + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("R2: frame 0 timeout") + .expect("R2: frame 0 item") + .expect("R2: frame 0 message"); + match &frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert!( + t.contains("room_ended"), + "R2: frame 0 must be the room_ended error frame; got: {t}" + ); + } + other => panic!("R2: frame 0 must be Text(room_ended error); got {other:?}"), + } + + // Admin disconnect: fires while handler is held at the hook. + // CommunityConnectionControl::disconnect_nip_fi (via registry scan) enqueues + // the Audio denial frame on terminal_ctrl_tx, wins AuthorizationDenied, + // then calls cancel.cancel(). + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "R2: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + // Release hook → handler calls lifecycle_cancel (loses reason, but cancel + // is set), awaits expiry task, then R2 drain delivers the admin's denial + // frame + reason.close_message() (1008 POLICY). + hook_release.notify_one(); + + // Frame 1: Audio authorization-denied JSON (R2 drain delivers admin denial). + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("R2: frame 1 timeout (denial frame must be delivered by R2 drain)") + .expect("R2: frame 1 item") + .expect("R2: frame 1 message"); + let expected_restricted = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match &frame1 { + tokio_tungstenite::tungstenite::Message::Text(t) => assert_eq!( + t.as_str(), + expected_restricted.as_str(), + "R2: frame 1 must be the Audio restricted JSON denial frame \ + (proves R2 drain delivered the admin-queued denial). \ + Mutation A: remove R2 drain → this times out." + ), + other => panic!("R2: frame 1 must be Text(restricted JSON); got {other:?}"), + } + + // Frame 2: 1008 POLICY close (reason.close_message() from AuthorizationDenied). + let frame2 = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("R2: frame 2 timeout") + .expect("R2: frame 2 item") + .expect("R2: frame 2 message"); + match &frame2 { + tokio_tungstenite::tungstenite::Message::Close(Some(cf)) => { + assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "R2: close code must be 1008 POLICY; got {:?}", + cf.code + ); + } + other => panic!("R2: frame 2 must be Close(Some(1008 POLICY)); got {other:?}"), + } + + server.abort(); + let _ = server.await; + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w9_expiry_before_participant_commit_rolls_back_48101_insert() { + super::w9_expiry_before_participant_commit_rolls_back_48101_insert_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w10_concurrent_committers_expiry_during_second_first_row_intact() { + super::w10_concurrent_committers_expiry_during_second_first_row_intact_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w10_reaffirm_expiry_during_second_same_pubkey_first_row_intact() { + super::w10_reaffirm_expiry_during_second_same_pubkey_first_row_intact_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cw5_auto_add_path_expiry_before_commit_rolls_back_both_rows() { + super::cw5_auto_add_path_expiry_before_commit_rolls_back_both_rows_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cw5_variant_concurrent_external_membership_add_preserved() { + super::cw5_variant_concurrent_external_membership_add_preserved_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cw8_post_add_peer_cancel_removes_peer_and_cleans_up_real_db() { + super::cw8_post_add_peer_cancel_removes_peer_and_cleans_up_real_db_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cw10_expiry_blocked_at_permit_barrier_until_fan_out_completes() { + super::cw10_expiry_blocked_at_permit_barrier_until_fan_out_completes_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cw10_full_handler_committed_join_produces_exactly_one_leave_event() { + super::cw10_full_handler_committed_join_produces_exactly_one_leave_event_body().await; + } + + /// F3: lifecycle_generation committed in kind-48101 JOIN matches the relay's + /// global liveness generation (the same value `handle_huddle_liveness_req` + /// returns for Off-mode rooms). + /// + /// This test is discoverable by the PostgreSQL Tests CI lane and exercises + /// the full DB-write path on a live Postgres instance. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f3_commit_participant_join_includes_lifecycle_generation() { + super::f3_commit_participant_join_includes_lifecycle_generation_body().await; + } + + /// F3 (wire): real admission → actual 48101 JOIN wire bytes → generation field + /// matches `state.huddle_liveness_generation` — the production Off-mode fallback + /// at `audio/handler.rs:802`. + /// + /// This is the production-bound complement to `f3_commit_participant_join_includes_lifecycle_generation`: + /// it drives the real handler to a committed JOIN, reads the actual wire bytes from + /// the DB, and asserts the generation field against the authoritative source. + /// Changing line 802 to a hardcoded UUID would fail this test but NOT the direct + /// `commit_participant_join` test (which supplies the generation as an input). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f3_wire_admission_generation_matches_liveness_source() { + super::f3_wire_admission_generation_matches_liveness_source_body().await; + } + + /// F4 (add-peer path): handler's `SessionExpired` arm at `audio_gate.acquire_effect()` + /// in Step 5 calls `expiry_deny_terminal` and delivers the Audio denial frame + + /// 1008 POLICY close before the socket drops. Exercised via the full handler. + /// + /// Discoverable by the PostgreSQL Tests CI lane (requires `seed_audio_fixture` DB writes). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f4_add_peer_expired_delivers_denial_before_close() { + super::f4_add_peer_expired_delivers_denial_before_close_body().await; + } + + /// F4 (commit path): handler's `JoinCommitError::Expired` arm calls `expiry_deny_terminal` + /// and delivers the Audio denial frame + 1008 POLICY close before the socket drops. + /// Exercised via the full handler with `before_participant_commit` hook. + /// + /// Discoverable by the PostgreSQL Tests CI lane (requires full DB commit path). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f4_commit_expired_delivers_denial_before_close() { + super::f4_commit_expired_delivers_denial_before_close_body().await; + } + + /// F4 (elapsed-deadline arm): the expiry task's real `tokio::time::sleep` fires, + /// pausing BEFORE `gate.expire()` so `cancel.is_cancelled() == false`. + /// The handler's `acquire_effect()` then returns `SessionExpired` via the + /// elapsed-deadline arm at `nip_fi_gate.rs:139-142` (not the cancel arm at 136-137). + /// + /// Discoverable by the PostgreSQL Tests CI lane (requires `seed_audio_fixture` + /// for the membership DB check and channel DB reads). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f4_elapsed_deadline_arm_fires_on_gate_acquire() { + super::f4_elapsed_deadline_arm_fires_on_gate_acquire_body().await; + } + + /// R2: room-ended admission error racing concurrent admin deny → R2 drain delivers + /// denial frame + 1008 POLICY close after the room_ended error frame. + /// + /// Discoverable by the PostgreSQL Tests CI lane (requires `seed_audio_fixture` + /// for the membership DB check in `handle_active_audio_connection`). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn r2_room_ended_concurrent_admin_delivers_denial_before_close() { + super::r2_room_ended_concurrent_admin_delivers_denial_before_close_body().await; + } + + /// R2 (drain_terminal!() path): non-member early exit with concurrent admin deny → + /// `drain_terminal!()`'s `lifecycle_cancel()` serializes admin's `try_send` so + /// the denial frame is delivered before close. + /// + /// Falsification target: remove only `lifecycle_cancel()` from `drain_terminal!()` → + /// handler drains before admin enqueues → denial absent → frame-1 timeout → RED. + /// + /// Discoverable by the PostgreSQL Tests CI lane (requires `seed_audio_fixture` + /// for the membership DB check). Uses a NON-MEMBER key so the not-a-member + /// arm fires. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn r2_drain_terminal_lifecycle_cancel_serializes_admin_frame() { + super::r2_drain_terminal_lifecycle_cancel_serializes_admin_frame_body().await; + } + } } diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index c003db9d8c3..e609a9d541e 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -1836,6 +1836,54 @@ impl RemoteHuddleSession { debug!(owner = %self.owner, "huddle media datagram to owner failed: {e}"); } } + + /// Construct a minimal `RemoteHuddleSession` for handler-level tests. + /// Fields not relevant to the test path (transport, seq, protocol_version) + /// are zeroed. Only `fenced` and `pubkey` are used by `send_clean_close`, + /// which is the only method CW7 exercises on this type. + #[cfg(test)] + pub fn for_test(fenced: FencedHeader, pubkey: String) -> Self { + use std::sync::Arc; + struct NullTransport; + impl buzz_relay_mesh::RelayPeerTransport for NullTransport { + fn send_datagram( + &self, + _to: buzz_relay_mesh::RuntimeId, + _dgram: buzz_relay_mesh::MeshDatagram, + ) -> Result<(), buzz_relay_mesh::MeshError> { + Ok(()) + } + fn open_session_stream( + &self, + _to: buzz_relay_mesh::RuntimeId, + _hello: buzz_relay_mesh::wire::StreamHello, + ) -> buzz_relay_mesh::BoxFuture< + '_, + Result, + > { + Box::pin(async { + Err(buzz_relay_mesh::MeshError::PeerNotConnected( + buzz_relay_mesh::RuntimeId([0u8; 32]), + )) + }) + } + fn set_inbound(&self, _handler: Box) {} + } + Self { + peer_index: 0, + epoch: 0, + protocol_version: 1, + roster: RosterSnapshot { + peers: vec![], + revision: 0, + }, + fenced, + owner: fenced.owner_runtime_id, + pubkey, + transport: Arc::new(NullTransport), + seq: 0, + } + } } /// Unregister the client from the owner and close the control stream cleanly. diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 5d831b3f651..8fb3f343119 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -367,6 +367,12 @@ pub struct Config { /// Whether the configured web bundle serves Git browser routes in addition /// to the public invite landing page. Defaults to false. pub serve_git_web_gui: bool, + + /// NIP-FI relay-level configuration: enforcement mode, issuer registry, + /// JWKS endpoints, and S4 admin-command fields. Parsed from + /// `BUZZ_NIP_FI_MODE` and `BUZZ_NIP_FI_ISSUERS`. Always present; mode + /// defaults to `Off` when the env vars are absent. + pub nip_fi: crate::nip_fi_config::NipFiRelayConfig, } fn parse_bind_addr(raw: &str) -> Result { @@ -1265,10 +1271,170 @@ impl Config { admin, web_dir, serve_git_web_gui, + nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?, }) } + + /// Construct a hermetic test configuration from deterministic explicit defaults. + /// + /// Makes **zero** direct or transitive calls to `Config::from_env()` and + /// performs **zero** process-environment reads. Every field is assigned a + /// hard-coded development default — the same value `from_env()` selects + /// when the corresponding variable is absent, except for + /// `git_hook_hmac_secret` which uses a fixed test-only 64-hex literal + /// instead of the random value that `from_env()` generates — so two calls + /// always produce an identical [`Config`]. + /// + /// The DB and Redis URLs use unreachable loopback endpoints (port 1) so + /// tests that never touch external services can construct an `AppState` + /// without side effects. + /// + /// The `nip_fi` field is constructed directly via + /// `NipFiRelayConfig::off_for_test()` (which is also env-free) so this + /// constructor never races with `nip_fi_config` tests that temporarily + /// mutate `BUZZ_NIP_FI_*` in the same test binary. + /// + /// Only available in test builds. + #[cfg(test)] + pub fn hermetic_for_test() -> Self { + // DB and Redis use port 1 (unreachable) so tests that call this but + // never actually connect to a DB/Redis don't accidentally hit a live + // service that happens to be running on the default dev ports. + let database_url = "postgres://buzz:buzz@127.0.0.1:1/buzz".to_string(); // sadscan:disable np.postgres.1 + let redis_url = "redis://127.0.0.1:1".to_string(); + + // git_repo_path / git_pack_cache_path: use a stable tmpdir-relative + // path. These directories are not created here; tests that exercise + // git functionality must create them themselves. + let git_repo_path = std::path::PathBuf::from("./repos"); + let git_pack_cache_path = git_repo_path.join(".pack-cache"); + + let git_max_pack_bytes: u64 = 500 * 1024 * 1024; // 500 MB + let git_max_repo_bytes: u64 = git_max_pack_bytes.saturating_mul(2); // 1 GB + let git_pack_cache_max_bytes: u64 = git_max_repo_bytes.saturating_mul(5); // 5 GB + + Self { + bind_addr: "0.0.0.0:3000".parse().expect("static default parses"), + database_url, + read_database_url: None, + replica_read_max_age_ms: 0, + drain_jitter_ms: 0, + redis_url, + redis_pool_size: 16, + db_pool_size: 50, + db_read_pool_size: None, + relay_url: "ws://localhost:3000".to_string(), + pairing_relay_url: None, + max_connections: 10_000, + max_concurrent_handlers: 1024, + send_buffer_size: 1_000, + max_frame_bytes: DEFAULT_MAX_FRAME_BYTES, + slow_client_grace_limit: 15, + auth: buzz_auth::AuthConfig::default(), + require_auth_token: false, + cors_origins: Vec::new(), + relay_private_key: None, + uds_path: None, + health_port: 8080, + metrics_port: 9102, + pubkey_allowlist_enabled: false, + require_relay_membership: false, + huddle_audio_available: true, + mesh: buzz_relay_mesh::MeshConfig { + enabled: false, + bind_addr: "0.0.0.0:3478".parse().expect("static default parses"), + registry_refresh: std::time::Duration::from_secs(15), + }, + mesh_demo_echo: false, + relay_owner_pubkey: None, + relay_operator_api_origin: None, + relay_operator_pubkeys: Vec::new(), + allow_nip_oa_auth: false, + klipy: None, + media: buzz_media::MediaConfig { + s3_endpoint: "http://localhost:9000".to_string(), + s3_access_key: "buzz_dev".to_string(), + s3_secret_key: "buzz_dev_secret".to_string(), + s3_bucket: "buzz-media".to_string(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media::config::S3AddressingStyle::default(), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 500 * 1024 * 1024, + max_file_bytes: 100 * 1024 * 1024, + public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }, + media_max_concurrent_uploads: 8, + media_max_concurrent_uploads_per_pubkey: 2, + media_uploads_per_minute: 30, + audit_enabled: true, + ephemeral_ttl_override: None, + git_repo_path, + git_pack_cache_path, + git_max_pack_bytes, + git_max_repo_bytes, + git_pack_cache_max_bytes, + git_pack_cache_max_concurrent_populations: 2, + git_max_repos_per_pubkey: 100, + git_max_concurrent_ops: 20, + // Fixed test-only 64-hex secret. The value is non-sensitive and + // intentionally constant so two calls to this constructor always + // produce an identical Config. + git_hook_hmac_secret: + "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + push_enabled: false, + push_executor_key_id: "relay-v1".to_string(), + push_gateway_delivery_url: None, + push_gateway_timeout: std::time::Duration::from_millis(2_000), + join_policy: None, + admin: None, + web_dir: None, + serve_git_web_gui: false, + nip_fi: crate::nip_fi_config::NipFiRelayConfig::off_for_test(), + } + } + + /// Like [`hermetic_for_test`] but overrides `database_url` from the + /// canonical CI/local-dev env-var chain + /// (`BUZZ_TEST_DATABASE_URL` → `DATABASE_URL`) + /// so DB-backed fixture helpers that call + /// `sqlx::PgPool::connect(&state.config.database_url)` reach a real + /// database instead of the unreachable port-1 stub. + /// + /// Use this when you need the NIP-FI env isolation of `hermetic_for_test` + /// **and** a real DB URL in `config.database_url` — e.g. the + /// `presence_storage_postgres_tests` or the W2 durable DB assertion. + /// + /// Falls back to the port-1 stub when neither env var is set, preserving + /// the correct fail-closed behaviour for unit tests that never touch a DB. + /// + /// Only available in test builds. + #[cfg(test)] + pub fn hermetic_for_test_with_db_from_env() -> Self { + let mut config = Self::hermetic_for_test(); + // Prefer the explicit CI wrapper var; fall back to the generic DATABASE_URL. + if let Some(url) = std::env::var("BUZZ_TEST_DATABASE_URL") + .ok() + .or_else(|| std::env::var("DATABASE_URL").ok()) + { + config.database_url = url; + } + config + } } +/// Process-wide mutex for tests that mutate environment variables. +/// +/// All test modules that read OR write environment variables (directly via +/// `std::env::set_var`/`remove_var` or indirectly via `Config::from_env`) +/// must hold this mutex for the duration of the test to prevent same-process +/// parallel mutation races. [F6: env-var serialization across crate modules] +#[cfg(test)] +pub(crate) static ENV_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod tests { use super::*; @@ -1284,10 +1450,35 @@ mod tests { assert!(!debug.contains("private-klipy-key")); } + #[test] + fn hermetic_for_test_is_deterministic() { + // Two calls must produce identical configs — no random or env-sourced + // fields may differ. This is the mandatory-red regression for the + // fixed HMAC secret: restoring the rand::random expression causes the + // two secrets to diverge and this assertion fails. + let a = Config::hermetic_for_test(); + let b = Config::hermetic_for_test(); + assert_eq!( + a.git_hook_hmac_secret, b.git_hook_hmac_secret, + "hermetic_for_test() must return identical git_hook_hmac_secret across calls" + ); + // The secret must be the fixed test-only value (non-empty, 64 hex chars). + assert_eq!(a.git_hook_hmac_secret.len(), 64); + assert!( + a.git_hook_hmac_secret + .chars() + .all(|c| c.is_ascii_hexdigit()), + "git_hook_hmac_secret must be 64 lowercase hex characters" + ); + } + // Mutex to serialize tests that mutate environment variables. // Parallel env-var mutation causes `defaults_are_valid` to see the invalid // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. - static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // This is an alias of the crate-wide ENV_TEST_MUTEX so NIP-FI env-mutation + // tests in nip_fi_config.rs serialize against Config::from_env callers here. + // [F6: env-var serialization] + use crate::config::ENV_TEST_MUTEX as ENV_MUTEX; /// Look up against a fixed set, standing in for process env. fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 12a450861e0..01e9bd8216a 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -84,6 +84,15 @@ pub struct ConnectionState { /// Separate channel with priority drain — if this channel fills too, /// the connection is closed (writer is completely stalled). pub ctrl_tx: mpsc::Sender, + /// Dedicated one-slot sender for the terminal NIP-FI denial frame. + /// + /// Because only one terminal event fires per connection lifetime (either key + /// pairing mismatch or session expiry, never both), this channel is always + /// available when the denial is enqueued — it cannot be saturated by ordinary + /// control traffic. The send_loop drains it in its cancel branch ahead of + /// `Close`, guaranteeing the denial frame is delivered even when `ctrl_tx` + /// (capacity 8) is full. [FI-INV-05, FI-TRACE-LEASE-BOUND] + pub terminal_ctrl_tx: mpsc::Sender, /// Token used to signal graceful shutdown of this connection's tasks. pub cancel: CancellationToken, /// Consecutive buffer-full events. Cancel only after `grace_limit`. @@ -92,6 +101,47 @@ pub struct ConnectionState { pub backpressure_count: Arc, /// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`). pub grace_limit: u8, + + /// The NIP-FI assertion presented at upgrade, when enforcement is enabled. + /// + /// `None` means the relay is in `Off` mode — no assertion is required. + /// When `Some`, the NIP-42 key pairing check uses this to enforce that + /// `assertion.asserted_key() == nip42_pubkey` unconditionally (S3 invariant: + /// no flag reads — S2 deleted `require_attested_key`). [FI-INV-05] + pub nip_fi_assertion: Option, + + /// The UTC deadline after which this connection's NIP-FI lease expires. + /// + /// `None` means no assertion-based lifetime is enforced (mode is `Off`). + /// When `Some`, the session-expiry task fires at this instant and sends + /// `restricted: authorization denied` + cancels. Equality is expired. + /// [FI-TRACE-LEASE-BOUND] + pub session_deadline: Option>, + + /// The NIP-FI session admission gate. Every WS connection has exactly one + /// gate — this is the [one-gate-per-connection] invariant. + /// + /// In enforce mode (assertion presented at upgrade), the gate has a + /// deadline and the expiry task calls `gate.expire()` at that deadline. + /// In off-mode (no assertion), the gate has no deadline and never + /// self-expires — `acquire_effect()` always succeeds unless the outer + /// cancel token fires. + /// + /// Handlers that perform irreversible side effects (AUTH state commit, + /// EVENT persistence, REQ subscription registration, COUNT query) must + /// call `gate.acquire_effect()` at the irreversible seam. The gate's + /// quiescence barrier ensures connection teardown (subscription removal, + /// peer cleanup) cannot start until all pre-expiry effects finish their + /// bounded commits. [FI-TRACE-LEASE-BOUND, one-gate-per-connection] + pub(crate) nip_fi_gate: std::sync::Arc, + + /// Shared transition lock for all terminal writers on this connection + /// (root key-pairing, expiry, community deletion, auth deny-set hit, + /// and the connection manager's close scan). All terminal writers go + /// through `CommunityConnectionControl` methods so no independently + /// writable reason-sender clone lives outside the primitive. + /// [FI-TRACE-CLOSE-CODE, FI-TRACE-CANCEL-RACE] + pub(crate) community_control: crate::state::CommunityConnectionControl, } impl ConnectionState { @@ -210,7 +260,7 @@ impl ConnectionState { if count >= self.grace_limit { warn!(conn_id = %self.conn_id, count, "sustained backpressure — closing slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); - self.cancel.cancel(); + self.community_control.lifecycle_cancel(); } else { warn!(conn_id = %self.conn_id, count, grace = self.grace_limit, "send buffer full — grace {count}/{}", self.grace_limit); } @@ -224,6 +274,45 @@ impl ConnectionState { } } +/// Compute the NIP-FI session deadline from a verified assertion and the +/// configured `max_connection_lifetime`. +/// +/// Per spec [FI-TRACE-LEASE-BOUND]: +/// ```text +/// session_deadline = min( +/// assertion.upstream_authority_deadline(), // min(exp, iat+max_age, key-snapshot-hard) +/// connection_time + max_connection_lifetime // partitions, never shortens +/// ) +/// ``` +/// +/// `upstream_authority_deadline()` already includes the key-snapshot hard +/// deadline (one of the three authority_deadlines terms), so this two-term min +/// covers all four normative terms. Equality at any deadline is expired. +/// +/// `connection_time` must be captured at or immediately before the WebSocket +/// upgrade — not after the NIP-42 exchange — so the partition is rooted at the +/// true connection establishment instant and the session cannot outlive +/// `connection_time + max_connection_lifetime` by the authentication interval. +pub(crate) fn compute_session_deadline( + assertion: &buzz_auth::VerifiedAssertion, + connection_time: chrono::DateTime, + max_connection_lifetime: Option, +) -> chrono::DateTime { + let upstream = assertion.upstream_authority_deadline(); + match max_connection_lifetime { + Some(lifetime) => { + let partition = match chrono::Duration::from_std(lifetime) { + Ok(d) => connection_time + d, + // lifetime so large it overflows chrono — treat as effectively + // infinite, so the upstream deadline wins. + Err(_) => chrono::DateTime::::MAX_UTC, + }; + upstream.min(partition) + } + None => upstream, + } +} + /// Owns authentication accounting for exactly the lifetime of the production /// connection future. Explicit teardown selects the precise close outcome; /// aborts and panics fall back to `disconnect` and cancel child tasks. @@ -249,7 +338,11 @@ impl AuthLifecycleGuard { impl Drop for AuthLifecycleGuard { fn drop(&mut self) { if !self.finished { - self.conn.cancel.cancel(); + // Use lifecycle_cancel (holds the transition lock) so a concurrent + // terminal writer that has won the reason but not yet enqueued its + // denial frame completes try_send before the cancel wakes the + // consumer. [FI-TRACE-CANCEL-RACE, B2 fix] + self.conn.community_control.lifecycle_cancel(); self.conn.finish_auth_on_close(AuthOutcome::Disconnect); } } @@ -264,6 +357,8 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, + nip_fi_assertion: Option, + connection_time: chrono::DateTime, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -278,11 +373,26 @@ pub async fn handle_connection( community_id, control, move || async move { check_state.db.is_community_active(community_id).await }, - move |control| handle_active_connection(socket, run_state, addr, tenant, conn_id, control), + move |control| { + handle_active_connection( + socket, + run_state, + addr, + tenant, + conn_id, + control, + nip_fi_assertion, + connection_time, + ) + }, ) .await; } +// `handle_active_connection` inherits the connection handler's natural parameter +// surface (socket, state, addr, tenant, conn_id, control, assertion, connection_time). +// Collapsing into a struct would just move the fields without reducing coupling. +#[allow(clippy::too_many_arguments)] async fn handle_active_connection( socket: WebSocket, state: Arc, @@ -290,9 +400,14 @@ async fn handle_active_connection( tenant: TenantContext, conn_id: Uuid, control: CommunityConnectionControl, + nip_fi_assertion: Option, + connection_time: chrono::DateTime, ) { let cancel = control.cancellation_token(); let disconnect_reason = control.disconnect_reason(); + // connection_time is threaded in from the HTTP handler (captured immediately + // before on_upgrade) so the session partition is rooted at the true upgrade + // instant, not the post-community-active-check instant. [FI-TRACE-LEASE-BOUND] let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -308,6 +423,10 @@ async fn handle_active_connection( // even when the data buffer is full. let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + // Dedicated one-slot channel for the terminal NIP-FI denial frame. + // Cannot be saturated by ordinary traffic — only one terminal event fires. + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel::(1); + // Dedicated restart-close channel carries a flush acknowledgement. Keeping // ordinary control frames unchanged avoids coupling heartbeat/ban traffic // to graceful-shutdown delivery tracking. @@ -316,6 +435,42 @@ async fn handle_active_connection( let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); + // Compute the NIP-FI session deadline from the assertion. + // + // Per spec (Request and session bounds, [FI-TRACE-LEASE-BOUND]): + // session_deadline = min( + // assertion.upstream_authority_deadline(), // = min(exp, iat+max_age, key-snapshot hard deadline) + // connection_time + max_connection_lifetime // partitions, never shortens per spec + // ) + // + // Equality at any deadline is expired. `upstream_authority_deadline()` already + // includes the key-snapshot hard deadline (one of the three authority_deadlines + // terms), so this min covers all normative terms. + let session_deadline = nip_fi_assertion.as_ref().map(|a| { + compute_session_deadline( + a, + connection_time, + state.config.nip_fi.max_connection_lifetime(), + ) + }); + + // Create the NIP-FI session admission gate when in enforce mode. + // + // The gate is the lifetime authority for this connection: handlers acquire + // Create the NIP-FI session admission gate. Every WS connection gets + // exactly one gate — the [one-gate-per-connection] invariant. + // + // Enforce mode (assertion + deadline): gate has a deadline; the expiry + // task calls gate.expire() at the deadline. + // Off-mode (no assertion): gate has no deadline and never self-expires; + // acquire_effect() always succeeds unless the outer cancel token fires. + // [FI-TRACE-LEASE-BOUND, one-gate-per-connection] + let nip_fi_gate = if let Some(deadline) = session_deadline { + crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()) + } else { + crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()) + }; + let conn = Arc::new(ConnectionState { conn_id, tenant, @@ -327,9 +482,14 @@ async fn handle_active_connection( subscriptions: Arc::clone(&subscriptions), send_tx: tx.clone(), ctrl_tx: ctrl_tx.clone(), + terminal_ctrl_tx, cancel: cancel.clone(), backpressure_count: Arc::clone(&backpressure_count), grace_limit: state.config.slow_client_grace_limit, + nip_fi_assertion, + session_deadline, + nip_fi_gate: nip_fi_gate.clone(), + community_control: control.clone(), }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); @@ -360,12 +520,14 @@ async fn handle_active_connection( conn_id, tx.clone(), ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), Some(restart_tx), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), subscriptions, state.config.slow_client_grace_limit, + control.clone(), ); let (ws_send, ws_recv) = socket.split(); @@ -375,21 +537,22 @@ async fn handle_active_connection( ws_send, rx, ctrl_rx, + terminal_ctrl_rx, restart_rx, send_cancel, disconnect_reason, )); let missed_pongs = Arc::new(AtomicU8::new(0)); - let heartbeat_cancel = cancel.clone(); let heartbeat_task = tokio::spawn(heartbeat_loop( ctrl_tx, Arc::clone(&missed_pongs), - heartbeat_cancel, + control.clone(), )); let auth_timeout_conn = Arc::clone(&conn); let auth_timeout_cancel = cancel.clone(); + let auth_timeout_control = control.clone(); let auth_timeout_task = tokio::spawn(async move { tokio::select! { _ = tokio::time::sleep(AUTH_TIMEOUT) => { @@ -400,13 +563,28 @@ async fn handle_active_connection( "NIP-42 auth timeout — closing connection" ); metrics::counter!("buzz_ws_auth_timeouts_total").increment(1); - auth_timeout_cancel.cancel(); + auth_timeout_control.lifecycle_cancel(); } } _ = auth_timeout_cancel.cancelled() => {} } }); + // NIP-FI session-lifetime enforcement task. + // + // Uses gate.expire() so the quiescence barrier (write lock) ensures + // connection teardown cannot start until all pre-expiry effects have + // finished. [FI-TRACE-LEASE-BOUND] + let nip_fi_expiry_task = conn.session_deadline.map(|deadline| { + crate::nip_fi_session::spawn_nip_fi_expiry_task( + deadline, + Arc::clone(&nip_fi_gate), + conn.terminal_ctrl_tx.clone(), + crate::nip_fi_session::NipFiWsRoute::Root, + control.clone(), + ) + }); + // Cancellation races database-backed AUTH work. This watcher claims the // pending lifecycle under the same lock as success/denial transitions, so // whichever terminal happens first wins and a late handler cannot overwrite it. @@ -432,7 +610,7 @@ async fn handle_active_connection( ) .await; - cancel.cancel(); + control.lifecycle_cancel(); let close_outcome = if state.shutting_down.load(Ordering::Acquire) { AuthOutcome::Shutdown } else { @@ -446,6 +624,9 @@ async fn handle_active_connection( let _ = heartbeat_task.await; let _ = auth_timeout_task.await; let _ = auth_cancel_task.await; + if let Some(task) = nip_fi_expiry_task { + let _ = task.await; + } for removed in state.sub_registry.remove_connection(conn.conn_id) { if removed.scope.is_global() { @@ -480,7 +661,7 @@ async fn handle_active_connection( drop(permit); } -/// Outbound send loop with control-frame priority. +/// Send WebSocket messages in priority order: control frames before data frames. /// /// Control frames (Pong, Close) are drained first on every iteration, /// giving them priority over data frames. If the underlying socket writer @@ -490,6 +671,7 @@ async fn send_loop( ws_send: futures_util::stream::SplitSink, data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, + terminal_ctrl_rx: mpsc::Receiver, restart_rx: mpsc::Receiver, cancel: CancellationToken, disconnect_reason: watch::Receiver>, @@ -498,6 +680,7 @@ async fn send_loop( ws_send, data_rx, ctrl_rx, + terminal_ctrl_rx, restart_rx, cancel, disconnect_reason, @@ -561,8 +744,15 @@ where /// Best-effort terminal delivery with one shared deadline. A socket that never /// becomes writable cannot retain its connection task or semaphore permit. +/// +/// Drain order: terminal_ctrl_rx (NIP-FI denial frame) → first_ctrl (the +/// in-flight ordinary control frame, if any) → ctrl_rx (remaining ordinary +/// control frames) → Close. The terminal channel must be drained first so a +/// queued denial frame is delivered even when the ordinary control channel +/// (capacity 8) is saturated with Pings/Pongs. [FI-INV-05, B1 fix] async fn flush_terminal_frames( sink: &mut S, + terminal_ctrl_rx: &mut mpsc::Receiver, ctrl_rx: &mut mpsc::Receiver, disconnect_reason: &watch::Receiver>, first_ctrl: Option, @@ -570,6 +760,16 @@ async fn flush_terminal_frames( S: Sink + Unpin, { let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; + // Terminal channel first — ensures denial frame precedes ordinary control + // and Close even when ctrl_rx is saturated. + while let Ok(terminal_msg) = terminal_ctrl_rx.try_recv() { + if !matches!( + tokio::time::timeout_at(deadline, sink.send(terminal_msg)).await, + Ok(Ok(())) + ) { + return; + } + } if let Some(ctrl_msg) = first_ctrl { if !matches!( tokio::time::timeout_at(deadline, sink.send(ctrl_msg)).await, @@ -596,6 +796,7 @@ async fn send_loop_inner( mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, + mut terminal_ctrl_rx: mpsc::Receiver, mut restart_rx: mpsc::Receiver, cancel: CancellationToken, disconnect_reason: watch::Receiver>, @@ -608,8 +809,11 @@ async fn send_loop_inner( match send_or_cancel(&mut ws_send, ctrl_msg.clone(), &cancel).await { WriterStep::Completed => {} WriterStep::Cancelled => { + // Cancelled mid-top-of-loop drain: drain terminal first, then + // the already-taken ctrl_msg, then remaining ctrl_rx, then Close. flush_terminal_frames( &mut ws_send, + &mut terminal_ctrl_rx, &mut ctrl_rx, &disconnect_reason, Some(ctrl_msg), @@ -627,27 +831,76 @@ async fn send_loop_inner( // cancellation can fall back to an unacknowledged close. biased; Some(restart) = restart_rx.recv() => { - let sent = matches!( - tokio::time::timeout( - WS_TERMINAL_FLUSH_TIMEOUT, - ws_send.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { - code: axum::extract::ws::close_code::RESTART, - reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), - }))), - ).await, - Ok(Ok(())) - ); - let _ = restart.flushed.send(sent); + // R1: an already-winning NIP-FI denial must be delivered before + // (and instead of) the 1012 restart close. If a NIP-FI denial + // has won the reason slot (disconnect_reason = AuthorizationDenied), + // wait for its frame to be enqueued — the reason is set before + // try_send under the transition lock, so we may see the reason + // before the frame arrives. Only when no denial reason is set + // do we send the 1012 as before. [FI-INV-05, R1 fix] + let deadline = tokio::time::Instant::now() + WS_TERMINAL_FLUSH_TIMEOUT; + let denial_reason = *disconnect_reason.borrow(); + // Attempt to receive the denial frame. If reason is set, + // wait up to the flush deadline for the enqueue to complete + // (tiny window between reason publication and try_send). + // If reason is unset, try_recv immediately (empty → no denial). + let maybe_frame = if matches!( + denial_reason, + Some(CommunityDisconnectReason::AuthorizationDenied) + ) { + // Reason won — wait for the frame with a bounded deadline. + // In the common case it is already present; in the race + // window it arrives within microseconds. + tokio::time::timeout_at(deadline, terminal_ctrl_rx.recv()) + .await + .ok() + .flatten() + } else { + terminal_ctrl_rx.try_recv().ok() + }; + if let Some(denial_frame) = maybe_frame { + // A denial already won: deliver it and its close code ahead + // of the restart 1012. The restart close is not sent + // (flushed=false) — the denial reason takes precedence. + if tokio::time::timeout_at(deadline, ws_send.send(denial_frame)) + .await + .is_ok_and(|r| r.is_ok()) + { + let close = disconnect_reason + .borrow() + .map_or(WsMessage::Close(None), |reason| reason.close_message()); + let _ = tokio::time::timeout_at(deadline, ws_send.send(close)).await; + } + let _ = restart.flushed.send(false); + } else { + let sent = matches!( + tokio::time::timeout_at( + deadline, + ws_send.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::RESTART, + reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), + }))), + ).await, + Ok(Ok(())) + ); + let _ = restart.flushed.send(sent); + } break; } _ = cancel.cancelled() => { - // Drain any queued control frames before closing. A ban - // disconnect queues its `OK false "blocked: …"` reason frame on - // ctrl and then cancels; without this drain the biased branch - // would send Close first and the client would never learn why - // (the top-of-loop drain does not run again after we break). - // This makes "queue frame on ctrl, then cancel" a safe idiom. - flush_terminal_frames(&mut ws_send, &mut ctrl_rx, &disconnect_reason, None).await; + // Drain the terminal NIP-FI denial frame first (if any), then + // ordinary control frames, before writing Close. The shared + // flush_terminal_frames helper applies a single bounded deadline + // to all three queues so a stalled socket cannot retain the + // writer task indefinitely. [FI-INV-05, B1 fix] + flush_terminal_frames( + &mut ws_send, + &mut terminal_ctrl_rx, + &mut ctrl_rx, + &disconnect_reason, + None, + ) + .await; break; } Some(ctrl_msg) = ctrl_rx.recv() => { @@ -656,6 +909,7 @@ async fn send_loop_inner( WriterStep::Cancelled => { flush_terminal_frames( &mut ws_send, + &mut terminal_ctrl_rx, &mut ctrl_rx, &disconnect_reason, Some(ctrl_msg), @@ -673,6 +927,7 @@ async fn send_loop_inner( WriterStep::Cancelled => { flush_terminal_frames( &mut ws_send, + &mut terminal_ctrl_rx, &mut ctrl_rx, &disconnect_reason, None, @@ -691,6 +946,7 @@ async fn send_loop_inner( WriterStep::Cancelled => { flush_terminal_frames( &mut ws_send, + &mut terminal_ctrl_rx, &mut ctrl_rx, &disconnect_reason, None, @@ -712,6 +968,7 @@ async fn send_loop_inner( WriterStep::Cancelled => { flush_terminal_frames( &mut ws_send, + &mut terminal_ctrl_rx, &mut ctrl_rx, &disconnect_reason, None, @@ -735,10 +992,11 @@ async fn send_loop_inner( async fn heartbeat_loop( ctrl_tx: mpsc::Sender, missed_pongs: Arc, - cancel: CancellationToken, + control: CommunityConnectionControl, ) { let mut interval = tokio::time::interval(Duration::from_secs(30)); loop { + let cancelled = control.cancellation_token(); tokio::select! { _ = interval.tick() => { // fetch_add returns the *previous* value before incrementing: @@ -748,16 +1006,16 @@ async fn heartbeat_loop( let missed = missed_pongs.fetch_add(1, Ordering::Relaxed); if missed >= 2 { warn!("3 missed pongs — closing connection"); - cancel.cancel(); + control.lifecycle_cancel(); break; } if ctrl_tx.try_send(WsMessage::Ping(axum::body::Bytes::new())).is_err() { warn!("control channel full — cannot send Ping, closing"); - cancel.cancel(); + control.lifecycle_cancel(); break; } } - _ = cancel.cancelled() => break, + _ = cancelled.cancelled() => break, } } } @@ -844,6 +1102,16 @@ async fn recv_loop( } async fn handle_text_message(text: String, conn: Arc, state: Arc) { + // B2: Frame admission fence. If the connection's NIP-FI session has already + // expired (cancel fired by the expiry task), drop this frame before any + // handler dispatch. This closes the window where a buffered EVENT/REQ/AUTH + // is selected from the recv queue after expiry fires the cancel token. + // The check at the top of handle_text_message covers all message types + // uniformly — no individual handler needs its own fence. + if conn.cancel.is_cancelled() { + return; + } + let msg = match ClientMessage::parse(&text) { Ok(m) => m, Err(e) => { @@ -975,6 +1243,8 @@ pub(crate) mod tests { ) -> (Arc, mpsc::Receiver) { let (send_tx, send_rx) = mpsc::channel(4); let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); let conn = ConnectionState { conn_id: Uuid::new_v4(), tenant: TenantContext::resolved( @@ -986,9 +1256,14 @@ pub(crate) mod tests { subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), send_tx, ctrl_tx, - cancel: CancellationToken::new(), + terminal_ctrl_tx, + cancel: cancel.clone(), backpressure_count: Arc::new(AtomicU8::new(0)), grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }; (Arc::new(conn), send_rx) } @@ -1385,6 +1660,8 @@ pub(crate) mod tests { tenant, Uuid::new_v4(), control, + None, + chrono::Utc::now(), ) .await; finished.notify_one(); @@ -1703,6 +1980,7 @@ pub(crate) mod tests { async fn cancelled_never_ready_sink_cannot_retain_writer_task() { let (data_tx, data_rx) = mpsc::channel(1); let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel(1); let (_restart_tx, restart_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let ready_polled = Arc::new(Notify::new()); @@ -1717,6 +1995,7 @@ pub(crate) mod tests { }, data_rx, ctrl_rx, + terminal_ctrl_rx, restart_rx, cancel.clone(), ordinary_disconnect_reason(), @@ -1748,6 +2027,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, CancellationToken::new(), ordinary_disconnect_reason(), @@ -1777,6 +2057,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, CancellationToken::new(), ordinary_disconnect_reason(), @@ -1811,6 +2092,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, CancellationToken::new(), ordinary_disconnect_reason(), @@ -1843,6 +2125,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, CancellationToken::new(), ordinary_disconnect_reason(), @@ -1880,6 +2163,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, CancellationToken::new(), ordinary_disconnect_reason(), @@ -1905,6 +2189,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, cancel, deleted_community_disconnect_reason(), @@ -1935,6 +2220,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, cancel, ordinary_disconnect_reason(), @@ -1969,6 +2255,7 @@ pub(crate) mod tests { sink, data_rx, ctrl_rx, + mpsc::channel(1).1, restart_rx, cancel, ordinary_disconnect_reason(), @@ -1992,4 +2279,1066 @@ pub(crate) mod tests { "ordinary cancellation retains the bare Close after the reason frame" ); } + + // ── NIP-FI session deadline — production function falsifiability ────────── + // + // These tests call `compute_session_deadline` directly (the production path + // used by `handle_connection`) with real `VerifiedAssertion` fixtures. + // Deleting or mutating `compute_session_deadline` turns these red. + + #[test] + fn deadline_exp_is_earliest_selects_exp() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + + let now = Utc::now(); + let exp = now + Duration::seconds(100); + let iat_max_age = now + Duration::seconds(300); + let key_hard = now + Duration::seconds(200); + // authority_deadlines = [exp, iat_max_age, key_hard] → min = exp + let assertion = VerifiedAssertion::for_test(None, vec![exp, iat_max_age, key_hard]); + let lifetime = std::time::Duration::from_secs(400); + let deadline = compute_session_deadline(&assertion, now, Some(lifetime)); + // exp < key_hard < lifetime; upstream = exp, partition >> exp → exp wins. + assert_eq!(deadline, exp, "exp is earliest upstream term"); + } + + #[test] + fn deadline_max_connection_lifetime_is_earliest_selects_partition() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + + let now = Utc::now(); + let exp = now + Duration::seconds(400); + let iat_max_age = now + Duration::seconds(300); + let key_hard = now + Duration::seconds(200); + // authority_deadlines = [exp, iat_max_age, key_hard] → upstream = key_hard (200s) + // lifetime partition = now + 100s < key_hard → partition wins. + let assertion = VerifiedAssertion::for_test(None, vec![exp, iat_max_age, key_hard]); + let lifetime = std::time::Duration::from_secs(100); + let deadline = compute_session_deadline(&assertion, now, Some(lifetime)); + // partition (now+100s) < upstream (now+200s) → partition wins. + let expected_partition = now + Duration::seconds(100); + // Allow 1s of wall-clock slack in the test. + let delta = if deadline > expected_partition { + (deadline - expected_partition).num_milliseconds().abs() + } else { + (expected_partition - deadline).num_milliseconds().abs() + }; + assert!(delta < 1000, "partition term should win; delta={delta}ms"); + } + + #[test] + fn deadline_no_lifetime_returns_upstream_only() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + + let now = Utc::now(); + let exp = now + Duration::seconds(600); + let key_hard = now + Duration::seconds(3600); + let assertion = VerifiedAssertion::for_test(None, vec![exp, key_hard]); + let deadline = compute_session_deadline(&assertion, now, None); + assert_eq!(deadline, exp, "no lifetime → upstream (exp) only"); + } + + // ── NIP-FI expiry notice delivered on terminal_ctrl_tx before cancel ───── + // + // The expiry task queues `restricted: authorization denied` on + // `terminal_ctrl_tx` (capacity-1, prioritised) BEFORE cancellation via the + // gate. This test invokes the production + // `nip_fi_session::spawn_nip_fi_expiry_task` constructor (Root route): + // an already-expired deadline fires immediately; the terminal channel carries + // the denial frame; the cancel fires afterward. + // + // Mutation evidence: + // A) Change the enqueue in `spawn_nip_fi_expiry_task` back to `ctrl_tx` → + // `terminal_rx.try_recv()` returns `Err`; test panics at "terminal + // channel must contain the denial frame". + // B) Delete `cancel.cancel()` inside gate.expire() → + // `cancel.is_cancelled()` is false; test panics at "expiry task must + // cancel the connection". + + #[tokio::test] + async fn expiry_notice_queued_on_ctrl_before_cancel() { + use tokio::sync::mpsc; + + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + + // Already-expired deadline → fires immediately. + let deadline = chrono::Utc::now() - chrono::Duration::seconds(10); + + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Invoke the production shared constructor — Root route. + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let expiry_task = crate::nip_fi_session::spawn_nip_fi_expiry_task( + deadline, + gate, + terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Root, + control, + ); + + tokio::time::timeout(std::time::Duration::from_secs(2), expiry_task) + .await + .expect("expiry task must complete within 2s") + .expect("expiry task must not panic"); + + // terminal_ctrl_rx must contain the denial frame. + let terminal_frame = terminal_ctrl_rx + .try_recv() + .expect("terminal channel must contain the denial frame before cancel"); + match terminal_frame { + WsMessage::Text(text) => { + // Root route: NOTICE format ["NOTICE", ]. + let v: serde_json::Value = serde_json::from_str(&text).expect("valid JSON"); + let payload = v.get(1).and_then(|c| c.as_str()).unwrap_or(""); + assert_eq!( + payload, + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + "terminal frame must carry the exact authorization_denied text" + ); + } + other => panic!("terminal frame must be Text, got {other:?}"), + } + // Cancel must have fired after the terminal send. + assert!( + cancel.is_cancelled(), + "expiry task must cancel the connection" + ); + } + + // ── B2: frame-admission fence and AUTH TOCTOU ───────────────────────────── + // + // Once the NIP-FI expiry task calls cancel(), no further message dispatch + // should occur — even if a frame was already buffered in the recv queue + // before cancel fired. + // + // The fence is the `if conn.cancel.is_cancelled() { return; }` check at the + // top of `handle_text_message`. These tests exercise two windows: + // + // 1. A buffered REQ/EVENT/COUNT frame that arrives after cancel fires. + // 2. An AUTH message dispatched while cancel is already set + // (the TOCTOU window where auth_state.write() is acquired, cancel is + // checked under the lock, and the write is skipped if cancelled). + // + // Mutation evidence: + // A) Remove `if conn.cancel.is_cancelled() { return; }` from + // `handle_text_message` → the EVENT test receives a frame on send_rx + // (an OK or NOTICE) → the assertion panics. + // B) Remove `if conn.cancel.is_cancelled() { return; }` from the AUTH + // handler (inside the write guard) → the AUTH test's + // `not Authenticated` assertion may still hold due to the DB path, but + // the top-level handle_text_message fence is the true gate. + + #[tokio::test] + async fn b2_cancelled_connection_event_frame_not_dispatched() { + use std::collections::HashMap; + + // Pre-cancel the token — simulates the expiry task having already fired. + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + + let conn = Arc::new(ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Failed), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let state = crate::state::tests::test_state().await; + // A plausible EVENT frame — the handler would normally send OK/NOTICE. + let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "b2 test") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let raw = serde_json::json!(["EVENT", event]).to_string(); + + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + // No frame must be sent — the fence must return before any handler runs. + assert!( + send_rx.try_recv().is_err(), + "B2: a pre-cancelled connection must not dispatch an EVENT frame to any handler" + ); + } + + // ── B3: send_loop writer delivers denial-then-Close through real send path ─ + // + // These tests drive the real `send_loop_inner` against a sink that records + // every frame, saturate ctrl_tx, enqueue a denial frame on terminal_ctrl_tx, + // then cancel the token. The sink is non-blocking (MockSink), so send_loop + // runs to completion synchronously after cancel fires. + // + // Assertion: the denial frame appears in the output BEFORE the Close frame. + // This proves the queue-then-cancel ordering holds through the actual writer + // code path, not just through a channel try_recv check. + // + // Mutation evidence: + // A) In send_loop_inner's cancel branch, swap the terminal drain and the + // ctrl drain → denial frame position flips → assertion panics. + // B) Remove the terminal drain entirely → denial frame absent → assertion + // panics on the "denial frame must precede Close" check. + + #[tokio::test] + async fn b3_root_pairing_denial_precedes_close_through_send_loop() { + use crate::nip_fi_session::NipFiWsRoute; + + let (data_tx, data_rx) = mpsc::channel::(16); + let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel::(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + + // Saturate ctrl_tx so an ordinary send couldn't carry the denial frame. + for i in 0..8u8 { + ctrl_tx + .try_send(WsMessage::Text(format!("ordinary-{i}").into())) + .expect("ctrl_tx has capacity 8"); + } + drop(data_tx); // no data traffic in this test + + // Enqueue the denial frame on the terminal channel, then cancel. + // This is the queue-then-cancel pattern the pairing denial path uses. + terminal_ctrl_tx + .try_send(crate::nip_fi_session::authorization_denied_frame( + NipFiWsRoute::Root, + )) + .expect("terminal channel is empty"); + cancel.cancel(); + + let (sink, state_arc) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + terminal_ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; + + let state = state_arc.lock().expect("mock sink poisoned"); + // The first frame written must be the denial frame. + // The last frame written must be Close (or None close). + let msgs = &state.messages; + assert!( + !msgs.is_empty(), + "send_loop must write at least the denial frame + Close" + ); + // Find the denial frame. + let denial_pos = msgs + .iter() + .position(|m| matches!(m, WsMessage::Text(t) if t.contains("authorization denied"))); + let close_pos = msgs.iter().rposition(|m| matches!(m, WsMessage::Close(_))); + + let denial_pos = denial_pos.expect("denial frame must appear in send_loop output"); + let close_pos = close_pos.expect("Close frame must appear in send_loop output"); + assert!( + denial_pos < close_pos, + "B3: denial frame (pos {denial_pos}) must precede Close frame (pos {close_pos})" + ); + } + + #[tokio::test] + async fn b3_expiry_denial_precedes_close_through_send_loop() { + use crate::nip_fi_session::{spawn_nip_fi_expiry_task, NipFiWsRoute}; + use chrono::Utc; + + let (data_tx, data_rx) = mpsc::channel::(16); + let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel::(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + + // Saturate ctrl_tx. + for i in 0..8u8 { + ctrl_tx + .try_send(WsMessage::Text(format!("ordinary-{i}").into())) + .expect("ctrl_tx has capacity 8"); + } + drop(data_tx); + + // Arm the expiry task with an already-expired deadline. It will + // immediately enqueue the denial frame on the terminal channel and + // cancel the token. + let already_expired = Utc::now() - chrono::Duration::seconds(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(already_expired, cancel.clone()); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let expiry_handle = spawn_nip_fi_expiry_task( + already_expired, + gate, + terminal_ctrl_tx, + NipFiWsRoute::Root, + control, + ); + // Wait for the expiry task to fire before we run the send_loop. + expiry_handle.await.expect("expiry task must complete"); + + let (sink, state_arc) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + terminal_ctrl_rx, + restart_rx, + cancel, + ordinary_disconnect_reason(), + ) + .await; + + let state = state_arc.lock().expect("mock sink poisoned"); + let msgs = &state.messages; + assert!( + !msgs.is_empty(), + "send_loop must write at least the denial frame + Close" + ); + let denial_pos = msgs + .iter() + .position(|m| matches!(m, WsMessage::Text(t) if t.contains("authorization denied"))); + let close_pos = msgs.iter().rposition(|m| matches!(m, WsMessage::Close(_))); + + let denial_pos = denial_pos.expect("expiry denial frame must appear in send_loop output"); + let close_pos = close_pos.expect("Close frame must appear in send_loop output"); + assert!( + denial_pos < close_pos, + "B3: expiry denial frame (pos {denial_pos}) must precede Close frame (pos {close_pos})" + ); + } + + // ── R1 witnesses: denial wins over concurrent restart. ─────────────────── + // + // Two complementary schedules that together prove the R1 fix: + // + // 1. b3_denial_precedes_restart_when_denial_already_won (below): + // Frame already on terminal_ctrl_rx when restart arm fires. Reason is + // set; recv() returns immediately with the existing frame. + // + // 2. r1_denial_precedes_restart_reason_won_frame_arrives_during_recv (below): + // Reason set (AuthorizationDenied) but terminal_ctrl_rx is EMPTY when the + // restart arm checks. The arm calls bounded recv(); a concurrent task + // enqueues the frame while recv() is waiting. This covers the race window + // documented at state.rs:295-310 where reason is published under the + // transition lock before try_send. + // + // Mutation evidence (applies to both): + // A) Remove the `disconnect_reason` watch check + recv() from the restart + // arm → restarts always send 1012 even when denial won → first assertion + // panics (denial frame missing or flushed=true). + // B) Keep reason check but use try_recv() only (no bounded recv()) → race + // schedule 2 returns Err(Empty) → 1012 sent instead of denial → panics. + + #[tokio::test] + async fn b3_denial_precedes_restart_when_denial_already_won() { + use crate::nip_fi_session::NipFiWsRoute; + use tokio::sync::mpsc; + + let (_data_tx, data_rx) = mpsc::channel::(16); + let (_ctrl_tx, ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel::(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel::(); + let cancel = CancellationToken::new(); + + // Enqueue the denial frame on the terminal channel. + let denial = crate::nip_fi_session::authorization_denied_frame(NipFiWsRoute::Root); + terminal_ctrl_tx + .try_send(denial.clone()) + .expect("terminal channel is empty"); + + // Queue a restart command — the biased select will fire restart_rx first. + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart close"); + + // Set the disconnect reason so denial close has a non-None close frame. + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + control.manager_disconnect_nip_fi( + &terminal_ctrl_tx, // already consumed — this is a no-op try_send + ); + let disconnect_reason = control.disconnect_reason(); + + let (sink, state_arc) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + terminal_ctrl_rx, + restart_rx, + cancel, + disconnect_reason, + ) + .await; + + // restart.flushed must be false — the restart 1012 was not sent. + assert_eq!( + flushed_rx.await, + Ok(false), + "R1: restart.flushed must be false when denial already won (1012 not sent)" + ); + + let state = state_arc.lock().expect("mock sink poisoned"); + let msgs = &state.messages; + assert!( + !msgs.is_empty(), + "R1: send_loop must write at least one frame when denial is queued" + ); + // First frame must be the denial frame, not a 1012 close. + assert!( + matches!(&msgs[0], WsMessage::Text(t) if t.contains("authorization denied")), + "R1: first frame must be the denial frame, not 1012 — \ + got {:?}. \ + Mutation: remove terminal_ctrl_rx check from restart arm → 1012 sent instead → panics.", + msgs.first() + ); + // Last frame must be a Close (the denial close code, not 1012). + let last = msgs.last().expect("at least one frame"); + match last { + WsMessage::Close(Some(close)) => { + assert_ne!( + close.code, + axum::extract::ws::close_code::RESTART, + "R1: close code must not be 1012 (restart) when denial won" + ); + assert_eq!( + close.code, + axum::extract::ws::close_code::POLICY, + "R1: close code must be 1008 (POLICY) when denial won; got {}. \ + Mutation: remove disconnect_reason.borrow() close_message() call → \ + wrong close code → panics.", + close.code + ); + } + WsMessage::Close(None) => { + panic!( + "R1: last frame must be Close(Some(1008 POLICY)), got Close(None) — \ + denial close code must be present when denial won the reason slot" + ); + } + other => panic!("R1: last frame must be Close, got {other:?}"), + } + } + + // ── R1 witness 2: reason won but frame not yet enqueued (recv path) ─────── + // + // This schedule exercises the specific race documented at state.rs:295-310: + // `disconnect_reason` is set to AuthorizationDenied under the transition + // lock BEFORE `try_send` completes. The restart arm may see the reason + // (AuthorizationDenied) but find terminal_ctrl_rx empty. The R1 fix calls + // bounded `recv()` in this case; the frame arrives while recv() is waiting. + // + // Setup: + // 1. disconnect_reason set to AuthorizationDenied (reason won). + // 2. terminal_ctrl_rx is EMPTY — frame not yet enqueued. + // 3. restart command queued — biased select fires restart arm. + // 4. Concurrently (after send_loop_inner starts), enqueue the denial frame. + // + // Mutation evidence: + // A) Replace recv() with try_recv() in the reason-won branch → + // try_recv() returns Err(Empty) → 1012 sent → flushed=true → + // flushed_rx assertion panics. + // B) Remove the disconnect_reason check entirely → always try_recv() → + // same result as A. + #[tokio::test] + async fn r1_denial_precedes_restart_reason_won_frame_arrives_during_recv() { + use crate::nip_fi_session::NipFiWsRoute; + use tokio::sync::mpsc; + + let (_data_tx, data_rx) = mpsc::channel::(16); + let (_ctrl_tx, ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel::(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel::(); + let cancel = CancellationToken::new(); + + // Set reason = AuthorizationDenied BUT do NOT enqueue the denial frame + // yet. We simulate the window between reason publication and try_send: + // use a raw watch channel to publish the reason without going through + // the full CommunityConnectionControl path (which would also try_send). + let (reason_tx, reason_rx) = + tokio::sync::watch::channel(Some(CommunityDisconnectReason::AuthorizationDenied)); + drop(reason_tx); // keep rx live; reason is already set + + // Queue the restart command — the biased select will fire restart_rx. + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart"); + + // Spawn a task that enqueues the denial frame after a brief yield, + // simulating try_send completing while the restart arm's recv() waits. + let denial = crate::nip_fi_session::authorization_denied_frame(NipFiWsRoute::Root); + let terminal_ctrl_tx_for_sender = terminal_ctrl_tx.clone(); + let denial_for_sender = denial.clone(); + tokio::spawn(async move { + // Yield to allow send_loop_inner to enter the restart arm and start + // its bounded recv() before the frame is available. + tokio::task::yield_now().await; + tokio::task::yield_now().await; + terminal_ctrl_tx_for_sender + .send(denial_for_sender) + .await + .expect("enqueue denial frame during recv wait"); + }); + + let (sink, state_arc) = MockSink::new(None); + send_loop_inner( + sink, + data_rx, + ctrl_rx, + terminal_ctrl_rx, + restart_rx, + cancel, + reason_rx, + ) + .await; + + // flushed must be false — denial won, restart 1012 not sent. + assert_eq!( + flushed_rx.await, + Ok(false), + "R1: restart.flushed must be false when denial reason is set \ + (1012 must not be sent even when frame arrives during recv)" + ); + + let state = state_arc.lock().expect("mock sink poisoned"); + let msgs = &state.messages; + assert!( + !msgs.is_empty(), + "R1: send_loop must write at least one frame when denial reason is set" + ); + // First frame must be the denial TEXT frame, not 1012. + assert!( + matches!(msgs.first(), Some(WsMessage::Text(t)) if t.contains("authorization denied")), + "R1: first frame must be the denial frame, not 1012 — got {:?}. \ + Mutation: use try_recv() instead of recv() → Err(Empty) → 1012 sent instead.", + msgs.first() + ); + // Last frame must be Close(1008 POLICY). + let last = msgs.last().expect("at least one frame"); + match last { + WsMessage::Close(Some(close)) => { + assert_eq!( + close.code, + axum::extract::ws::close_code::POLICY, + "R1: close code must be 1008 POLICY when denial reason won; got {}", + close.code + ); + } + WsMessage::Close(None) => { + panic!("R1: last frame must be Close(Some(1008 POLICY)), got Close(None)"); + } + other => panic!("R1: last frame must be Close, got {other:?}"), + } + } + + // ── F5 loopback teardown: real connection epilogue removes subs + topic refcounts ── + // + // Proves the complete F5 teardown boundary using the production + // `handle_active_connection` call path and real `ConnectionManager`/`sub_registry`/ + // `pubsub` infrastructure — no synthetic setup. + // + // Sequence: + // 1. Build test state (no Postgres needed for REQ; auth uses DB for moderation, + // so this test runs in CI where DATABASE_URL is set). + // 2. Start a plain loopback WS server calling `handle_active_connection`. + // 3. Connect + complete NIP-42 auth (registers pubkey in conn_manager). + // Auth OK check verifies success flag (v[2] == true) to reject ban/internal errors. + // 4. Send a REQ ["REQ", "sub1", {"kinds":[13534]}]. + // Kind 13534 (NIP-43 membership list) hits `filters_are_nip43_membership_only` + // → skips DB accessible-channels lookup → passes p_gated/engram/author-only gates + // → is not huddle-liveness or search → reaches `acquire_effect()` and + // the `after_req_permit_acquired` hook (permit is held). + // 5. Call `state.conn_manager.disconnect_nip_fi(pubkey)` from the test. + // This: wins reason → enqueues denial frame → fires cancel. + // The expiry task is now in quiescence (write lock blocked by permit). + // 6. Release hook → handler proceeds: registers subscription in sub_registry, + // retains Global topic in pubsub, finishes REQ, drops permit. + // 7. Quiescence unblocks → expiry task completes. + // 8. recv_loop detects cancel → sends denial frame + POLICY close → + // connection epilogue: + // nip_fi_expiry_task.await (already done) + // sub_registry.remove_connection → 0 subscriptions + // pubsub.release_topic(Global) → 0 topic refcounts + // 9. Observe exactly one canonical denial frame then the POLICY close on the WS. + // 10. Wait for connection_finished. + // 11. Assert: total_subscriptions() == 0 and topic_refcount(Global) == 0 + // and topic_refcount(Channel) == 0. + // + // Mutation evidence: + // A) Remove `gate.quiesce().await` from expiry task cancel arm → + // task exits before REQ registers → remove_connection finds nothing → + // sub orphans after hook release → total_subscriptions() stays 1 at step 11 + // if the connection_finished fires before remove_connection (hard race). + // More reliably: `task_handle.is_finished()` check added below panics. + // B) Remove `sub_registry.remove_connection` from connection epilogue → + // sub never removed → total_subscriptions() stays 1 → assertion panics. + // C) Remove `pubsub.release_topic` from connection epilogue → + // topic refcount stays 1 → assertion panics. + // D) Delete `after_req_permit_acquired(...)` from req.rs → + // arrived_rx times out → test panics (proves hook is at correct seam). + // + // Runs in the PG lane (`postgres_tests` wrapper below): auth's moderation + // check needs Postgres and the admission gate needs Redis. + async fn f5_loopback_teardown_epilogue_removes_subscription_and_topic_refcount_body() { + use axum::{extract::ws::WebSocketUpgrade, routing::get, Router}; + use buzz_auth::VerifiedAssertion; + use buzz_pubsub::EventTopic; + use chrono::{Duration, Utc}; + use nostr::{EventBuilder, Keys, RelayUrl}; + use tokio::net::TcpListener; + use tokio::sync::Notify; + use tokio_tungstenite::{connect_async, tungstenite::Message}; + use uuid::Uuid; + + // F5 requires Postgres (auth's moderation check) and Redis (the + // recv_loop admission gate; the default test state's redis://127.0.0.1:1 + // would close REQ as "shared admission unavailable"). Both come from the + // PG lane's environment and a missing or unreachable one fails the test. + let db_url = crate::test_support::required_database_url(); + let pool = sqlx::PgPool::connect(&db_url) + .await + .unwrap_or_else(|e| panic!("F5: wrapper DB {db_url} unreachable: {e}")); + let redis_url = crate::test_support::required_redis_url(); + let state_arc = crate::state::tests::test_state_with_database_pool(pool.clone()).await; + let redis_pool = deadpool_redis::Config::from_url(&redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap_or_else(|e| panic!("F5: invalid Redis URL {redis_url}: {e}")); + if let Err(e) = redis_pool.get().await { + panic!("F5: Redis at {redis_url} unreachable: {e}"); + } + let mut state_mut = (*state_arc).clone(); + state_mut.admission_rate_limiter = + std::sync::Arc::new(buzz_pubsub::rate_limiter::RedisRateLimiter::new(redis_pool)); + let state = std::sync::Arc::new(state_mut); + // Use a fresh non-nil UUID for the F5 test community. + // The DB has a `chk_communities_id_not_nil` constraint that rejects Uuid::nil(), + // and a unique constraint on `lower(host)` that requires each community to have a + // distinct host. Derive the host from the UUID for per-test-run uniqueness. + let f5_community_uuid = Uuid::new_v4(); + let f5_community_host = format!("f5-test-{}.local", f5_community_uuid); + let tenant = TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(f5_community_uuid), + f5_community_host.clone(), + ); + // Far-future deadline so the gate is live but does NOT self-expire. + // disconnect_nip_fi provides the deny path; the expiry task enters + // quiescence when cancel fires. + let member_keys = Keys::generate(); + let assertion = VerifiedAssertion::for_test( + Some(member_keys.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + let pubkey_bytes = member_keys.public_key().to_bytes().to_vec(); + let community_id = tenant.community(); + + let expected_relay_url: RelayUrl = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant) + .parse() + .expect("F5: expected NIP-42 relay URL"); + + let connection_finished = Arc::new(Notify::new()); + + let route_state = Arc::clone(&state); + let route_tenant = tenant.clone(); + let route_finished = Arc::clone(&connection_finished); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("F5: bind test listener"); + let addr = listener.local_addr().expect("F5: listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get(move |ws: WebSocketUpgrade| { + let state = Arc::clone(&route_state); + let tenant = route_tenant.clone(); + let finished = Arc::clone(&route_finished); + let assertion = assertion_c.clone(); + async move { + ws.on_upgrade(move |socket| async move { + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel); + handle_active_connection( + socket, + state, + "127.0.0.1:1234".parse().expect("client addr"), + tenant, + Uuid::new_v4(), + control, + Some(assertion), + chrono::Utc::now(), + ) + .await; + finished.notify_one(); + }) + } + }), + ); + axum::serve(listener, app) + .await + .expect("F5: serve lifecycle WS"); + }); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("F5: connect client"); + + // ── NIP-42 auth exchange ────────────────────────────────────────── + let challenge_frame = + tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("F5: challenge timeout") + .expect("F5: challenge item") + .expect("F5: challenge message"); + let challenge_text = match challenge_frame { + Message::Text(t) => t.to_string(), + other => panic!("F5: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("F5: challenge JSON"); + assert_eq!(challenge_json[0], "AUTH", "F5: expected AUTH message"); + let challenge = challenge_json[1].as_str().expect("F5: challenge field"); + + let auth_event = EventBuilder::auth(challenge, expected_relay_url) + .sign_with_keys(&member_keys) + .expect("F5: sign NIP-42 AUTH"); + client + .send(Message::Text( + serde_json::json!(["AUTH", auth_event]).to_string().into(), + )) + .await + .expect("F5: send auth"); + + // Drain OK message and any following messages until connection confirms + // auth (OK ["OK", event_id, true, ""] message). Allow up to 3s for auth. + // We check v[2] == true to reject OK(false) from ban/internal failures. + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + let frame = client + .next() + .await + .expect("F5: auth response item") + .expect("F5: auth response message"); + if let Message::Text(t) = &frame { + let v: serde_json::Value = serde_json::from_str(t).unwrap_or_default(); + if v[0] == "OK" && v[2] == true { + break; + } + } + } + }) + .await + .expect( + "F5: auth OK (success) timeout — DB required for auth (run in CI with DATABASE_URL)", + ); + + // ── Seed a channel + membership for the channel-topic assertion ─── + // + // A channel-scoped REQ retains EventTopic::Channel; the channel-topic + // release at connection.rs:638-642 is the production cleanup we bind. + // RED: delete connection.rs:638-642 → channel topic stays at 1 after + // teardown → the channel_refcount_after assertion below panics. + // + // The global sub (sub1) tests the Global topic path. + // The channel sub (sub_channel) tests the Channel topic path. + // + // Seed: fresh community (non-nil UUID, per chk_communities_id_not_nil constraint) + // + a fresh channel + membership for member_keys so accessibility check passes. + let f5_channel_id = uuid::Uuid::new_v4(); + let creator_bytes = member_keys.public_key().to_bytes().to_vec(); + // Insert the test community (non-nil UUID; ON CONFLICT DO NOTHING for idempotency). + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING", + ) + .bind(f5_community_uuid) + .bind(&f5_community_host) + .execute(&pool) + .await + .expect("F5: seed test community"); + // Insert a fresh stream channel under the test community. + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'f5-channel-topic', 'stream', 'open', $3) \ + ON CONFLICT (community_id, id) DO NOTHING", + ) + .bind(f5_channel_id) + .bind(f5_community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("F5: seed test channel"); + // Insert member_keys pubkey as a channel member. + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(f5_community_uuid) + .bind(f5_channel_id) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("F5: seed channel membership"); + + // ── Send channel-scoped REQ: registers sub + retains Channel topic ─ + // + // This REQ completes synchronously (no hook, no permit race) and returns + // EOSE. After EOSE, sub_channel is registered and Channel topic refcount = 1. + // The hook fires only for the NEXT REQ (sub1). + client + .send(Message::Text( + serde_json::json!(["REQ", "sub_channel", {"#h": [f5_channel_id.to_string()]}]) + .to_string() + .into(), + )) + .await + .expect("F5: send channel REQ"); + + // Wait for EOSE from sub_channel (confirms sub registered + channel topic retained). + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let frame = client + .next() + .await + .expect("F5: channel EOSE item") + .expect("F5: channel EOSE message"); + if let Message::Text(t) = &frame { + let v: serde_json::Value = serde_json::from_str(t).unwrap_or_default(); + if v[0] == "EOSE" && v[1] == "sub_channel" { + break; + } + } + } + }) + .await + .expect("F5: channel sub EOSE timeout — channel REQ must complete and send EOSE"); + + // Verify channel topic is held (refcount = 1) before disconnect. + // This establishes the positive precondition: teardown must release it. + let channel_refcount_before = state + .pubsub + .topic_refcount(&tenant, EventTopic::Channel(f5_channel_id)) + .await; + assert_eq!( + channel_refcount_before, 1, + "F5: channel topic refcount must be 1 after channel REQ registration" + ); + + // ── Global REQ completes first: positive Global precondition ───── + // + // sub_global registers a global subscription and retains + // EventTopic::Global before any disconnect, so the post-teardown + // zero is a real 1 → 0 transition. + // RED (b): delete the Global release_topic in the connection epilogue + // (connection.rs:632-637) → global refcount stays 1 → zero assert panics. + client + .send(Message::Text( + serde_json::json!(["REQ", "sub_global", {"kinds": [13534]}]) + .to_string() + .into(), + )) + .await + .expect("F5: send global REQ"); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let frame = client + .next() + .await + .expect("F5: global EOSE item") + .expect("F5: global EOSE message"); + if let Message::Text(t) = &frame { + let v: serde_json::Value = serde_json::from_str(t).unwrap_or_default(); + if v[0] == "EOSE" && v[1] == "sub_global" { + break; + } + } + } + }) + .await + .expect("F5: global sub EOSE timeout"); + assert_eq!( + state + .pubsub + .topic_refcount(&tenant, EventTopic::Global) + .await, + 1, + "F5: Global topic refcount must be 1 after sub_global registration" + ); + assert_eq!( + state.sub_registry.total_subscriptions(), + 2, + "F5: sub_channel + sub_global must both be registered before disconnect" + ); + + // ── Separate racing REQ: hold its effect permit ─────────────────── + let (arrived_rx, hook_release) = + crate::nip_fi_test_hooks::req_permit_acquired_hook::arm(community_id); + client + .send(Message::Text( + serde_json::json!(["REQ", "sub_race", {"kinds": [13534]}]) + .to_string() + .into(), + )) + .await + .expect("F5: send racing REQ"); + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect( + "F5: racing REQ must reach after_req_permit_acquired within 5s \ + (permit held). Mutation D: delete the hook call → times out.", + ) + .expect("F5: hook arrived channel closed"); + + // ── Admin disconnect: real production path ──────────────────────── + let closed = state + .conn_manager + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "F5: conn_manager.disconnect_nip_fi must find exactly 1 connection" + ); + + // ── Terminal delivery WHILE the racing permit is still held ─────── + let denial_frame = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("F5: denial frame timeout") + .expect("F5: denial frame item") + .expect("F5: denial frame message"); + let expected_denial = crate::protocol::RelayMessage::notice( + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + ); + match &denial_frame { + Message::Text(t) => assert_eq!( + t.as_str(), + expected_denial.as_str(), + "F5: denial frame must be the canonical Root NOTICE denial frame" + ), + other => panic!("F5: expected Text denial frame; got {other:?}"), + } + let close_frame = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("F5: close frame timeout") + .expect("F5: close frame item") + .expect("F5: close frame message"); + match &close_frame { + Message::Close(Some(cf)) => assert_eq!( + cf.code, + tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy, + "F5: close code must be 1008 POLICY; got {:?}", + cf.code + ), + other => panic!("F5: expected Close(Some(1008)); got {other:?}"), + } + + // ── Blocked-epilogue assert (permit still held) ─────────────────── + // + // Terminal delivery is complete, but the expiry task's cancel arm is + // parked in gate.quiesce() on the held permit, and the epilogue awaits + // that task before remove_connection. Teardown must not have run. + // RED (a): delete `gate.quiesce().await` at nip_fi_session.rs:272 → + // epilogue runs while the permit is held → connection_finished fires + // (and registrations are removed) → this assertion panics. + let finished_early = tokio::time::timeout( + std::time::Duration::from_millis(500), + connection_finished.notified(), + ) + .await; + assert!( + finished_early.is_err(), + "F5: connection teardown epilogue must stay blocked while the racing REQ \ + permit is held (quiescence barrier)" + ); + assert_eq!( + state.sub_registry.total_subscriptions(), + 2, + "F5: epilogue must not have removed subscriptions while the permit is held" + ); + + // ── Release the permit → REQ registers → quiescence → teardown ─── + hook_release.notify_one(); + tokio::time::timeout( + std::time::Duration::from_secs(10), + connection_finished.notified(), + ) + .await + .expect("F5: connection must finish within 10s after the permit is released"); + // ── Assert zero orphan subscriptions ───────────────────────────── + assert_eq!( + state.sub_registry.total_subscriptions(), + 0, + "F5: sub_registry must have zero subscriptions after connection teardown. \ + Mutation B: remove sub_registry.remove_connection from epilogue → stays 1 → fails." + ); + + // ── Assert zero Global topic refcount ──────────────────────────── + // Kind 13534 (NIP-43 membership) is a global subscription (no #h channel tag), + // so only EventTopic::Global is retained. After epilogue it must be released. + let global_refcount = state + .pubsub + .topic_refcount(&tenant, EventTopic::Global) + .await; + assert_eq!( + global_refcount, 0, + "F5: global topic refcount must be zero after connection teardown. \ + Mutation C: remove pubsub.release_topic from epilogue → stays 1 → fails." + ); + + // ── Assert zero Channel topic refcount ─────────────────────────── + // The sub_channel subscription retains EventTopic::Channel(f5_channel_id). + // After epilogue, the channel-topic release at connection.rs:638-642 must + // have been called exactly once, bringing the refcount from 1 to 0. + // + // RED mutation: delete connection.rs:638-642 (the channel-topic release) → + // the refcount stays at 1 after teardown → this assertion panics. + // This binds the channel-cleanup regression to a real production mutation. + let channel_refcount_after = state + .pubsub + .topic_refcount(&tenant, EventTopic::Channel(f5_channel_id)) + .await; + assert_eq!( + channel_refcount_after, 0, + "F5: channel topic refcount must be zero after connection teardown. \ + The sub_channel subscription retained Channel({f5_channel_id}); \ + epilogue must release it via connection.rs:638-642. \ + RED: delete the channel release loop → refcount stays 1 → panics." + ); + + server.abort(); + let _ = server.await; + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn f5_loopback_teardown_epilogue_removes_subscription_and_topic_refcount() { + super::f5_loopback_teardown_epilogue_removes_subscription_and_topic_refcount_body() + .await; + } + } } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 930fa7af797..292f4d439fe 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -147,13 +147,29 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; - // Community ban gate (NIP-42 seam). Runs immediately after auth - // verification succeeds and before the allowlist and relay-membership - // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the - // MOD-7/M20 invariant (a ban must block connection auth even for open - // channels — enforcement is structural, not filtered later). A banned - // principal gets the standard protocol denial and the connection is - // dropped with zero further processing. + // NIP-FI key pairing [FI-INV-05]: immediately after successful + // verify_auth_event, before community-ban/allowlist/membership gates. + // Pre-DB positioning means a denied caller pays zero DB cost and the + // production call site is falsifiable without live tenant policy. + // [FI-TRACE-DENIAL-ORACLE post-establishment] + if crate::nip_fi_session::enforce_nip_fi_key_pairing( + conn.nip_fi_assertion.as_ref(), + pubkey, + crate::nip_fi_session::PairingDenialTarget::Root(conn.as_ref()), + ) + .await + == crate::nip_fi_session::PairingOutcome::Denied + { + return; + } + + // Community ban gate (NIP-42 seam). Runs after NIP-FI pairing and + // before the allowlist and relay-membership gates, per + // COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the MOD-7/M20 + // invariant (a ban must block connection auth even for open channels — + // enforcement is structural, not filtered later). A banned principal + // gets the standard protocol denial and the connection is dropped with + // zero further processing. // // NIP-OA cascade: a ban on the authenticated pubkey blocks it directly; // a ban on its cryptographically-proven owner cascades to the agent @@ -216,11 +232,15 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // Route the reason frame on the control channel (not `send`, // which uses the data channel and would race the cancel), so // the send loop drains it ahead of the Close it emits on - // cancel. Then cancel to close the socket immediately. + // cancel. Then use lifecycle_cancel (holds the transition lock) + // rather than raw cancel.cancel(), ensuring that a concurrent + // expiry writer that has won the reason but hasn't yet enqueued + // its denial frame completes its try_send before the cancel + // wakes the consumer. [FI-TRACE-CANCEL-RACE, B2 fix] let _ = conn.ctrl_tx.try_send(WsMessage::Text( RelayMessage::ok(&event_id_hex, false, deny_reason).into(), )); - conn.cancel.cancel(); + conn.community_control.lifecycle_cancel(); return; } } @@ -352,13 +372,99 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + // B2: acquire a session effect permit before committing auth state. + // + // Gate ordering: acquire_effect() obtains the fair read lock, then + // checks cancel and deadline. A permit is returned only when the + // session is still active — expiry cannot transition to Expired + // while any permit is held (the permit IS the read lock). This + // replaces the old "acquire write_lock → check cancel" fence with + // a stronger bound: no AUTH commit can start after the gate's + // deadline passes or after the expiry task's cancel.cancel() fires, + // and any AUTH commit that starts under a permit will complete before + // the gate's quiescence barrier allows teardown to proceed. + // + // Off-mode (no gate): no permit is needed; proceed unconditionally. + // [FI-TRACE-LEASE-BOUND, B2 seam: AUTH commit] + // + // Test hook: fires immediately before acquire_effect so a test can + // arm expiry between the NIP-42 verification success and the permit + // acquisition. This is the exact async gap W1 (auth barrier witness) + // exercises. No-op in production (cfg(test) only, Mutex unless + // armed). [nip_fi_test_hooks::auth_commit_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_auth_commit(conn.tenant.community()).await; + let _auth_permit = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => return, + }; if !conn.authenticate(auth_ctx) { return; } - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); + // The permit is held through set_authenticated_pubkey and the OK send + // so the entire auth commit is atomic with respect to expiry. + + // Register the proven key together with its admitting NIP-FI issuer + // (the same string the deny check below uses) BEFORE the deny-set + // check, so an issuer-scoped close scan can match this session. + state.conn_manager.set_authenticated_identity( + conn_id, + pubkey.to_bytes().to_vec(), + conn.nip_fi_assertion + .as_ref() + .map(|a| a.identity().issuer().to_owned()), + ); + + // Test hook: fires immediately after set_authenticated_pubkey (registration) + // and before the deny-set check, so a straddle test can insert a deny entry + // in the exact window between registration and check. No-op in production. + // [nip_fi_test_hooks::deny_set_check_hook, W_deny_straddle] + #[cfg(test)] + crate::nip_fi_test_hooks::before_deny_set_check(conn.tenant.community()).await; + + // Step 6 (NIP-FI.md:227-233): deny-set check — runs AFTER + // registration so any concurrent disconnect either sees this + // session in the close scan OR we see the deny entry here. + // Both sides of the straddle are covered; neither side can miss. + // [FI-TRACE-DENY-SET] + if let Some(assertion) = &conn.nip_fi_assertion { + if let Some(asserted_key) = assertion.asserted_key() { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if deny_map.is_denied( + assertion.identity().issuer(), + &asserted_key, + chrono::Utc::now(), + ) { + warn!( + conn_id = %conn_id, + pubkey = %pubkey.to_hex(), + "NIP-FI deny-set hit at post-registration check — denying" + ); + metrics::counter!( + "buzz_nip_fi_admission_denied_total", + "reason" => "deny_set_post_registration" + ) + .increment(1); + // Route through the shared transition primitive: acquires the + // terminal_frame_tx lock, first-writer-wins the reason, enqueues + // the denial frame only if this call wins, then drops the lock. + // Cancellation follows after the primitive returns, ensuring a + // concurrent disconnect_community cannot fire cancel.cancel() + // before the winning payload enqueue completes. + // [FI-TRACE-CLOSE-CODE, FI-TRACE-CANCEL-RACE] + conn.community_control.auth_deny_terminal( + &conn.terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Root, + ); + conn.cancel.cancel(); + return; + } + } + } + } + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + // _auth_permit drops here — expiry's write guard may proceed. } Err(e) => { warn!(conn_id = %conn_id, error = %e, "NIP-42 auth failed"); @@ -384,6 +490,7 @@ mod tests { use crate::api::relay_members::MembershipDecision; use crate::connection::{tests::test_conn_with_auth, AuthState}; use crate::metrics::{AuthOutcome, AuthPostTerminalState}; + use axum::extract::ws::Message as WsMessage; use metrics_util::debugging::DebugValue; use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag}; use std::time::Instant; @@ -474,6 +581,654 @@ mod tests { assert_eq!(extract_auth_tag_json(&event), None); } + // ── Witness A: Root pairing mismatch through the real root denial path ──── + // + // Drives the production `handle_auth`, NOT the shared function alone. + // The NIP-FI pairing call site is pre-DB: it fires immediately after + // `verify_auth_event` succeeds, before any community-ban/allowlist/membership + // DB gate. A lazy DB pool suffices — the test returns before any DB read. + // + // Mutation evidence: + // - Delete the production call from `handle_auth` → no Denied; test panics + // on AuthState (not Failed) or ctrl frame (absent) assertions. + // - Delete the denial branch inside `enforce_nip_fi_key_pairing` → same. + // - Emit on send_tx instead of ctrl_tx → ctrl frame assertion panics. + // - Omit `AuthState::Failed` → auth_state assertion panics. + // - Omit `cancel.cancel()` → cancellation assertion panics. + + async fn auth_test_state() -> std::sync::Arc { + use std::sync::Arc; + // hermetic_for_test: env-free, uses port-1 sockets — never races NIP-FI + // env-var mutations from nip_fi_config tests. [F6: ambient NIP-FI fixture race] + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + // 100ms acquire timeout: the port-1 stub must fail fast instead of + // waiting out sqlx's 30s default, keeping the unit lane quick. + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy(&config.database_url) + .expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + /// Like `auth_test_state` but connects to a real Postgres. + /// + /// Required for W1: the ban-check path is fail-closed, so a lazy-pool error + /// causes the handler to deny before reaching `before_auth_commit`. With the + /// real DB, an unknown pubkey/community returns `BanOutcome::Clear`. + /// + /// The DB is the PG lane wrapper's per-test database; a missing or + /// unreachable DB panics — these witnesses never skip. + async fn auth_test_state_real_db() -> std::sync::Arc { + use std::sync::Arc; + let db_url = crate::test_support::required_database_url(); + // Probe connectivity before constructing the full state. + if let Err(e) = sqlx::PgPool::connect(&db_url).await { + panic!("wrapper DB {db_url} unreachable: {e}"); + } + // hermetic_for_test: env-free, never races NIP-FI env-var mutations. + // Override database_url to the wrapper DB probed above. [F6] + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + config.database_url = db_url; + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn handle_auth_pairing_mismatch_runs_full_root_denial_path() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Key A named in assertion; key B signs the NIP-42 event — mismatch. + let key_a = Keys::generate(); + let key_b = Keys::generate(); + + let assertion = VerifiedAssertion::for_test( + Some(key_a.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let challenge = "test-challenge-A".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + let auth_control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: auth_control, + }); + + let state = auth_test_state().await; + + // relay_url = ws:// where scheme prefix is from config + // (default ws://), and host is "test.local". + let relay_url = "ws://test.local"; + let auth_event = EventBuilder::new(Kind::Authentication, "") + .tag(Tag::parse(["relay", relay_url]).unwrap()) + .tag(Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key_b) + .unwrap(); + + // Drive the production handle_auth path. + handle_auth(auth_event, Arc::clone(&conn), state).await; + + assert!( + cancel.is_cancelled(), + "connection must be cancelled on pairing mismatch" + ); + assert!( + matches!(conn.auth_state_snapshot(), AuthState::Failed), + "auth_state must be Failed after pairing mismatch" + ); + let ctrl_frame = terminal_ctrl_rx + .try_recv() + .expect("terminal channel must contain the denial notice frame"); + // Terminal queue must hold exactly one frame — no duplicate denial. + assert!( + terminal_ctrl_rx.try_recv().is_err(), + "terminal channel must hold exactly one frame after pairing mismatch" + ); + // ctrl_tx (ordinary queue) must be empty — denial goes to terminal only. + assert!( + ctrl_rx.try_recv().is_err(), + "ordinary ctrl channel must be empty after pairing denial (frame goes to terminal)" + ); + assert!( + send_rx.try_recv().is_err(), + "denial must not appear on the data channel" + ); + // Assert the full wire text byte-for-byte. + let expected_notice = crate::protocol::RelayMessage::notice( + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + ); + match ctrl_frame { + WsMessage::Text(text) => { + assert_eq!( + text, + expected_notice, + "terminal frame must be byte-identical to RelayMessage::notice(\"restricted: authorization denied\")" + ); + } + other => panic!("terminal frame must be Text(NOTICE); got {other:?}"), + } + } + + // ── B2: Cancelled connection is never admitted to Authenticated state ────── + // + // The B2 fence at the admission boundary (`if conn.cancel.is_cancelled() { + // return; }`) prevents committing `AuthState::Authenticated` after the NIP-FI + // expiry task has cancelled the connection in the async gap between dispatch + // and admission. + // + // This test pre-cancels the token and confirms that after `handle_auth` the + // connection is NOT `Authenticated`. The mechanism varies: on the test + // lazy-DB path, the ban check also denies (DbError path) — but the invariant + // holds regardless of which guard fires first. + // + // Mutation evidence: + // Removing the B2 fence is only observable in the narrow async window where + // the ban gate succeeds AND cancel fires after it. In the unit-test context + // the DB gate fires first; in a real deployment the B2 fence is the guard + // for that window. The test asserts the invariant (never Authenticated when + // cancelled) and documents the expected runtime behavior. + #[tokio::test] + async fn b2_pre_cancelled_connection_never_becomes_authenticated() { + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Use the same key for both assertion and NIP-42 event (no pairing mismatch). + // The cancel token is pre-cancelled to simulate the B2 window. + let key = Keys::generate(); + let assertion = buzz_auth::VerifiedAssertion::for_test( + Some(key.public_key()), + vec![Utc::now() + Duration::hours(1)], + ); + + let challenge = "test-challenge-B2".to_string(); + let (send_tx, _send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + + // Pre-cancel the token — simulates the expiry task having already fired. + let cancel = CancellationToken::new(); + cancel.cancel(); + let b2_control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: b2_control, + }); + + let state = auth_test_state().await; + let relay_url = "ws://test.local"; + let auth_event = EventBuilder::new(Kind::Authentication, "") + .tag(Tag::parse(["relay", relay_url]).unwrap()) + .tag(Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + handle_auth(auth_event, Arc::clone(&conn), state).await; + + // Regardless of the path taken (B2 fence, DB error, etc.), the + // connection MUST NOT be in Authenticated state when it was already + // cancelled before handle_auth ran. + assert!( + !matches!(conn.auth_state_snapshot(), AuthState::Authenticated(_)), + "B2: a pre-cancelled connection must never reach AuthState::Authenticated" + ); + } + + // ── W1 (auth barrier): expiry fired mid-flight blocks AUTH commit ───────── + // + // Arms `before_auth_commit` — the hook immediately before `acquire_effect()` + // in the AUTH commit path. Dispatches `handle_auth` with a live (not-yet- + // expired) gate, waits for the hook to signal the handler reached the + // permit boundary, fires the gate expiry (cancel), then releases the hook. + // The handler tries `acquire_effect()` and gets `SessionExpired`, returns + // without committing `AuthState::Authenticated`. + // + // This is the real barrier test Paul requires: the handler runs through + // NIP-42 verification, pairing check, ban check, allowlist, and membership + // gates, then stalls at `before_auth_commit`. Expiry fires *in that async + // gap*. The permit acquisition fails, and no auth commit occurs. + // + // Hook location: `handlers/auth.rs`, immediately before `acquire_effect()` + // at the B2 AUTH commit seam. + // + // Mutation evidence: + // A) Delete `#[cfg(test)] before_auth_commit(...)` from auth.rs → handler + // never stalls at the hook → cancel fires before handler reaches + // acquire_effect → handler completes auth before cancel is checked + // (race) OR the gate denies anyway on cancel check. The test is + // non-deterministic without the hook; WITH the hook the barrier is exact. + // B) Remove `acquire_effect()` from auth.rs → handler commits + // AuthState::Authenticated despite the cancel → assertion panics. + // C) Change gate from deadline-with-cancel to off_mode → acquire_effect + // succeeds even after cancel → handler commits auth → assertion panics. + // + // Runs in PG lane on wrapper DB: the ban-check path precedes the hook. The DB call returns + // "not banned" for an unknown community/pubkey — a real result, not mocked. + async fn w1_auth_barrier_expiry_mid_flight_blocks_auth_commit_body() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Same key for assertion and NIP-42 event — pairing passes. + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let challenge = "w1-barrier-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + + // Live gate — NOT pre-cancelled. acquire_effect succeeds unless we fire expiry. + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let w1_control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: w1_control, + }); + + // W1 requires a real DB (ban-check is fail-closed; lazy pool errors → deny before hook). + let state = auth_test_state_real_db().await; + let relay_url = "ws://test.local"; + let auth_event = EventBuilder::new(Kind::Authentication, "") + .tag(Tag::parse(["relay", relay_url]).unwrap()) + .tag(Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + // Arm the barrier: fires when handle_auth reaches before_auth_commit. + let (arrived_rx, release) = crate::nip_fi_test_hooks::auth_commit_hook::arm(community); + + // Spawn handle_auth — it will stall at the hook. + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = tokio::spawn(async move { handle_auth(auth_event, conn2, state2).await }); + + // Wait for the handler to reach the permit boundary. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W1: handler must reach before_auth_commit within 5s") + .expect("arrived channel closed"); + + // Fire expiry: cancel the gate's token so acquire_effect returns SessionExpired. + cancel.cancel(); + + // Release the hook — handler resumes and calls acquire_effect(). + release.notify_one(); + + // Wait for handle_auth to return. + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W1: handle_auth must return within 5s after hook release") + .expect("handle_auth task must not panic"); + + // Auth state must NOT be Authenticated — the permit was denied. + assert!( + !matches!(conn.auth_state_snapshot(), AuthState::Authenticated(_)), + "W1: auth_state must NOT be Authenticated after mid-flight expiry" + ); + + // No OK(true) must be on the data channel — auth was not committed. + while let Ok(frame) = send_rx.try_recv() { + if let WsMessage::Text(t) = &frame { + assert!( + !t.contains("\"true\"") && !t.contains(r#"[true"#), + "W1: no OK(true) must be sent when auth is denied by gate; got: {t}" + ); + } + } + } + + // ── W_deny_straddle: deny entry inserted in window between registration and check + // + // Arms `before_deny_set_check` — the hook immediately AFTER + // `set_authenticated_pubkey` (registration) and BEFORE the `is_denied` call. + // The key starts absent from the deny map. Once registration occurs the + // handler stalls at the hook. At the hook, the test: + // 1. Inserts the deny entry into the live map. + // 2. Executes the real ConnectionManager::disconnect_nip_fi (close-scan side): + // asserts it finds exactly 1 registered session — proving registration is + // visible to a concurrent disconnect in this exact window. + // 3. Releases the hook — the handler resumes and calls is_denied() (check side). + // Both sides are exercised; neither can miss. The connection is cancelled and + // the exact `authorization_denied` NOTICE frame is asserted; no OK(true) sent. + // + // Mutation evidence (executed on green baseline): + // A) Delete `#[cfg(test)] before_deny_set_check(...)` from auth.rs → + // handler never stalls → deny entry inserted AFTER check runs and + // missed → close_scan returns 0 (session deregistered) → assertion panics. + // B) Remove the `is_denied` check entirely → same outcome as (A). + // C) Move hook to before `set_authenticated_pubkey` (registration) → + // handler stalls before registration → close-scan `disconnect_nip_fi` + // returns 0 (not yet registered) → "exactly 1 session" assertion panics. + // Causally falsifies the registration-before-check invariant. + // + // Runs in PG lane on wrapper DB (same constraint as W1: ban-check is fail-closed). + async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body() { + use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Same key for assertion and NIP-42 event — pairing passes. + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + // `for_test` produces issuer = "test-issuer". + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let challenge = "w-deny-straddle-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Use a unique community UUID so this test's deny_set_check_hook slot + // does not collide with other concurrent tests (audio-active uses Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let deny_straddle_control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: deny_straddle_control, + }); + + // Real DB required (ban-check is fail-closed; lazy pool denies before hook). + let mut state = + Arc::try_unwrap(auth_test_state_real_db().await).unwrap_or_else(|arc| (*arc).clone()); + + // Wire an empty deny map for issuer "test-issuer" (the issuer used by + // VerifiedAssertion::for_test). No entries yet — the key is clean. + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + vec![IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + // Retain a handle so we can insert the entry during the hook window. + let deny_map_for_insert = Arc::clone(&deny_map); + state.nip_fi_deny_map = Some(deny_map); + let state = Arc::new(state); + + // Register the connection with conn_manager so set_authenticated_pubkey + // (called by handle_auth after NIP-42 succeeds) stores the pubkey — the + // close-scan side calls disconnect_nip_fi which iterates over registered + // connections. Without this registration, set_authenticated_pubkey is a + // no-op and disconnect_nip_fi always returns 0. + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, // no restart_tx for this unit-test fixture + cancel.clone(), + community, + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + // Arm the barrier: fires when handle_auth reaches before_deny_set_check. + let (arrived_rx, release) = crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // Spawn handle_auth — it will stall at the hook after registration. + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = tokio::spawn(async move { handle_auth(auth_event, conn2, state2).await }); + + // Wait for the handler to reach the deny-check seam. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W_deny_straddle: handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Handler is now AFTER set_authenticated_pubkey (registered) and BEFORE + // the deny check. Insert the deny entry into the live map. + let until = Utc::now() + Duration::seconds(3600); + let merge = deny_map_for_insert.merge_cross_pod_deny( + "test-issuer", + &key.public_key(), + until, + Utc::now(), + ); + assert!( + matches!(merge, buzz_auth::CrossPodMergeResult::Merged), + "W_deny_straddle: deny entry must be inserted during the hook window" + ); + + // Close-scan side: run the real ConnectionManager::disconnect_nip_fi now + // that the connection is registered. This proves the registration is visible + // to the concurrent close scan — the normative invariant [FI-TRACE-DENY-SET]. + // With the deny entry live, the scan finds exactly one session matching this + // pubkey and closes it. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .conn_manager + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_deny_straddle: close scan must find exactly 1 registered session \ + (proves registration is visible between the hook and the check)" + ); + + // Release the hook — handler resumes and calls is_denied(). + release.notify_one(); + + // Wait for handle_auth to return. + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W_deny_straddle: handle_auth must return within 5s after hook release") + .expect("handle_auth task must not panic"); + + // The connection must be cancelled — the deny check closed it. + assert!( + cancel.is_cancelled(), + "W_deny_straddle: connection must be cancelled after deny-set hit \ + (entry inserted between registration and check)" + ); + + // The denial frame must be on the terminal channel (authorization_denied). + // Both the close-scan side (manager_disconnect_nip_fi) and the check side + // (auth_deny_terminal) enqueue on terminal_ctrl_tx, which has capacity-1 + // and first-writer-wins semantics — exactly one frame lands there. + let terminal_frame = terminal_ctrl_rx + .try_recv() + .expect("W_deny_straddle: terminal channel must contain the denial frame"); + if let WsMessage::Text(t) = &terminal_frame { + let expected = crate::protocol::RelayMessage::notice( + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + ); + assert_eq!( + t.as_str(), + expected.as_str(), + "W_deny_straddle: terminal frame must be exact authorization_denied NOTICE; got: {t}" + ); + } else { + panic!("W_deny_straddle: terminal frame must be Text(NOTICE); got {terminal_frame:?}"); + } + // ctrl channel must be empty — denial goes to terminal only. + assert!( + ctrl_rx.try_recv().is_err(), + "W_deny_straddle: ctrl channel must be empty (denial goes to terminal channel)" + ); + + // No OK(true) on the data channel. + while let Ok(frame) = send_rx.try_recv() { + if let WsMessage::Text(t) = &frame { + assert!( + !t.contains("\"true\"") && !t.contains(r#"[true"#), + "W_deny_straddle: no OK(true) must be sent when deny-set catches \ + the entry inserted between registration and check; got: {t}" + ); + } + } + } + #[test] fn ban_decisions_map_to_bounded_public_outcomes() { assert_eq!(ban_denial(BanOutcome::Clear), None); @@ -663,4 +1418,178 @@ mod tests { 1 ); } + + // ── W_deny_other_issuer_straddle: a deny for (A, K) in the registration→check + // window neither closes nor refuses a session admitted under B with key K. + // + // Same seam as W_deny_straddle. The session's assertion is issued by B + // ("test-issuer"); at the hook the test inserts a deny entry for issuer A + // and runs A's close scan (root + audio), which must match nothing. After + // release the B session completes admission: OK(true), not cancelled. + // Mutation: a pubkey-only scan closes the B session → `closed == 0` fails. + // [FI-TRACE-DENY-SET] + // + // Runs in PG lane on wrapper DB (ban-check is fail-closed). + async fn w_deny_other_issuer_straddle_leaves_session_admitted_body() { + use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + const ISSUER_A: &str = "https://issuer-a.example"; + const ISSUER_B: &str = "test-issuer"; // VerifiedAssertion::for_test + + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + assert_eq!(assertion.identity().issuer(), ISSUER_B); + + let challenge = "w-deny-other-issuer-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let mut state = + Arc::try_unwrap(auth_test_state_real_db().await).unwrap_or_else(|arc| (*arc).clone()); + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + [ISSUER_A, ISSUER_B] + .into_iter() + .map(|issuer| IssuerCapacity { + issuer: issuer.to_owned(), + capacity: 16, + }) + .collect(), + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + let state = Arc::new(state); + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, + cancel.clone(), + community, + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + let (arrived_rx, release) = crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + let handle = tokio::spawn(handle_auth( + auth_event, + Arc::clone(&conn), + Arc::clone(&state), + )); + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Issuer A's command lands in the window: entry + both close scans. + assert!(matches!( + deny_map.merge_cross_pod_deny( + ISSUER_A, + &key.public_key(), + Utc::now() + Duration::seconds(3600), + Utc::now(), + ), + buzz_auth::CrossPodMergeResult::Merged + )); + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .conn_manager + .disconnect_nip_fi(ISSUER_A, &pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(ISSUER_A, &pubkey_bytes); + assert_eq!( + closed, 0, + "issuer A's close scan must not match a session admitted under B" + ); + + release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("handle_auth must return within 5s after hook release") + .expect("handle_auth task must not panic"); + + assert!( + !cancel.is_cancelled(), + "B session must not be cancelled by A's deny" + ); + assert!( + terminal_ctrl_rx.try_recv().is_err(), + "B session must get no denial frame" + ); + assert!( + matches!( + *conn.auth_state.lock().unwrap(), + AuthState::Authenticated(_) + ), + "B session must complete admission" + ); + let mut ok_true = false; + while let Ok(WsMessage::Text(t)) = send_rx.try_recv() { + ok_true |= t.contains("\"OK\"") && t.contains("true"); + } + assert!(ok_true, "B session must receive OK(true)"); + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w1_auth_barrier_expiry_mid_flight_blocks_auth_commit() { + super::w1_auth_barrier_expiry_mid_flight_blocks_auth_commit_body().await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { + super::w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body() + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_other_issuer_straddle_leaves_session_admitted() { + super::w_deny_other_issuer_straddle_leaves_session_admitted_body().await; + } + } } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 5ea30b238b6..20ecdf8bdda 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -100,6 +100,23 @@ pub async fn handle_count( accessible_channels.retain(|channel_id| allowed.contains(channel_id)); } + // B2: acquire effect permit immediately before the first DB count query. + // The permit is held through all count queries and the COUNT response. + // Off-mode: proceed unconditionally. + // [FI-TRACE-LEASE-BOUND, B2 seam: COUNT query] + // + // Test hook: fires immediately before acquire_effect. + // [nip_fi_test_hooks::count_query_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_count_query(conn.tenant.community()).await; + let _count_permit = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::closed(&sub_id, "restricted: session expired")); + return; + } + }; + // For each filter, count matching events with channel access enforcement. let mut total: u64 = 0; for (filter, requested_channels) in filters.iter().zip(requested_channel_sets) { @@ -314,3 +331,131 @@ pub async fn handle_count( } conn.send(RelayMessage::count(&sub_id, total)); } + +#[cfg(test)] +mod tests { + use super::*; + + // ── W4: B2 COUNT gate — barrier expiry mid-flight blocks count query ──────── + // + // Arms `before_count_query` — the hook immediately before `acquire_effect()` + // in the COUNT query path. Dispatches `handle_count` with a live (not-yet- + // cancelled) gate, waits for the hook to signal the handler reached the permit + // boundary, fires expiry (cancel), then releases the hook. The handler tries + // `acquire_effect()` and gets `SessionExpired`, sends CLOSED without issuing + // any DB query or modifying any state. + // + // Hook location: `handlers/count.rs`, immediately before `acquire_effect()`. + // + // Mutation evidence: + // A) Delete `#[cfg(test)] before_count_query(...)` from count.rs → + // hook never fires → `arrived_rx` times out → test panics. + // B) Remove `acquire_effect()` from count.rs → handler falls through to the + // DB path. With a lazy pool the query errors out, but the gate boundary is + // gone — the CLOSED message changes from "session expired" → assertion panics. + // C) Change gate to `off_mode` → `acquire_effect()` succeeds after cancel + // → handler proceeds, no CLOSED sent at all → `try_recv()` returns `Err` + // → assertion panics. + + async fn w4_b2_count_barrier_expiry_mid_flight_blocks_count_query_body() { + use nostr::Keys; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let keys = Keys::generate(); + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + + // Live gate — NOT pre-cancelled. acquire_effect succeeds unless we fire expiry. + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: keys.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let state = crate::state::tests::test_state().await; + let sub_id = "w4-barrier-test".to_string(); + // Kind:1 (TextNote) — not p-gated — so the filter clears all pre-gate + // authorization checks and reaches the `before_count_query` hook. + let filters = vec![nostr::Filter::new().kind(nostr::Kind::TextNote).limit(1)]; + + // Arm the barrier: fires when handle_count reaches before_count_query. + let (arrived_rx, release) = crate::nip_fi_test_hooks::count_query_hook::arm(community); + + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = + tokio::spawn(async move { handle_count(sub_id, filters, conn2, state2).await }); + + // Wait for the handler to reach the permit boundary. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W4: handler must reach before_count_query within 5s") + .expect("arrived channel closed"); + + // Fire expiry: cancel so acquire_effect returns SessionExpired. + cancel.cancel(); + + // Release — handler resumes, calls acquire_effect(), gets SessionExpired. + release.notify_one(); + + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W4: handle_count must return within 5s after hook release") + .expect("handle_count task must not panic"); + + // A CLOSED frame must have been sent with the session-expired message — + // no DB query was issued. + let frame = send_rx + .try_recv() + .expect("W4: handler must send CLOSED on expired gate"); + match frame { + axum::extract::ws::Message::Text(t) => { + assert!( + t.contains("session expired"), + "W4: CLOSED message must contain 'session expired'; got: {t}" + ); + } + other => panic!("W4: expected Text CLOSED frame, got {other:?}"), + } + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w4_b2_count_barrier_expiry_mid_flight_blocks_count_query() { + super::w4_b2_count_barrier_expiry_mid_flight_blocks_count_query_body().await; + } + } +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b5477ecef97..1e91057149d 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -415,6 +415,8 @@ async fn dispatch_persistent_event_inner( None => EventTopic::Global, }; state.mark_local_event(tenant.community(), &stored_event.event.id); + #[cfg(test)] + crate::nip_fi_test_hooks::before_event_publish(tenant.community()); if let Err(e) = state .pubsub .publish_event(tenant, topic, &stored_event.event) @@ -729,6 +731,20 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: session expired", + )); + return; + } + }; match handle_ephemeral_event( event, conn_id, @@ -771,6 +787,28 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: session expired", + )); + return; + } + }; + match super::ingest::ingest_event(&state, &conn.tenant, event, ingest_auth).await { Ok(result) => { if result.accepted { @@ -1127,6 +1165,34 @@ async fn handle_agent_observer_event( } } + // B2: acquire effect permit immediately before the irreversible side effects + // (mark_local_event + Redis publish + local fan-out). Holds through fan-out + // so expiry cannot cancel the connection mid-publication. + // + // Schedule without this permit: validation and owner lookup complete, the + // session expires (admin disconnect), the already-spawned task resumes and + // publishes to a live agent — recipient access checks do not validate + // publisher authority. The permit fences this resumed-task scenario: + // if the gate is expired, SessionExpired is returned and publication is + // skipped. [FI-TRACE-LEASE-BOUND, F1: observer permit] + // + // Test hook: fires immediately before acquire_effect so a test can arm expiry + // in the async gap between owner-validation and permit acquisition. + // [nip_fi_test_hooks::observer_publication_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_observer_publication(conn.tenant.community()).await; + let _observer_permit = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: session expired", + )); + return; + } + }; + state.mark_local_event(conn.tenant.community(), &event.id); if let Err(e) = state .pubsub @@ -1443,6 +1509,7 @@ mod tests { let (send_tx, mut send_rx) = mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); let conn = Arc::new(crate::connection::ConnectionState { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), @@ -1459,9 +1526,18 @@ mod tests { subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx, ctrl_tx, + terminal_ctrl_tx, cancel: CancellationToken::new(), backpressure_count: Arc::new(AtomicU8::new(0)), grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( + CancellationToken::new(), + ), + community_control: crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), }); super::handle_agent_observer_event( @@ -1521,6 +1597,8 @@ mod tests { let keys = Keys::generate(); let (send_tx, mut send_rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { conn_id: Uuid::new_v4(), tenant: tenant.clone(), @@ -1537,23 +1615,34 @@ mod tests { subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx, ctrl_tx, - cancel: CancellationToken::new(), + terminal_ctrl_tx, + cancel: cancel.clone(), backpressure_count: Arc::new(AtomicU8::new(0)), grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let watcher = Uuid::new_v4(); let (tx, mut rx) = mpsc::channel(10); let (ctrl, _ctrl_rx) = mpsc::channel(10); + let (terminal_ctrl, _terminal_ctrl_rx2) = mpsc::channel(1); + let watcher_cancel = CancellationToken::new(); + let watcher_control = + crate::state::CommunityConnectionControl::new(watcher_cancel.clone()); state.conn_manager.register( watcher, tx, ctrl, + terminal_ctrl, None, - CancellationToken::new(), + watcher_cancel, tenant.community(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + watcher_control, ); state.sub_registry.register_scoped( tenant.community(), @@ -1666,6 +1755,8 @@ mod tests { let keys = Keys::generate(); let (send_tx, mut send_rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); let conn = Arc::new(crate::connection::ConnectionState { conn_id: Uuid::new_v4(), tenant: tenant.clone(), @@ -1682,9 +1773,14 @@ mod tests { subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx, ctrl_tx, - cancel: CancellationToken::new(), + terminal_ctrl_tx, + cancel: cancel.clone(), backpressure_count: Arc::new(AtomicU8::new(0)), grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); // Same watcher registration as the ACK/fan-out cases, proving // the storage failure still reaches no subscriber while its @@ -1692,16 +1788,22 @@ mod tests { let watcher = Uuid::new_v4(); let (tx, mut rx) = mpsc::channel(10); let (ctrl, _ctrl_rx) = mpsc::channel(10); + let (terminal_ctrl2, _terminal_ctrl_rx2) = mpsc::channel(1); + let watcher_cancel = CancellationToken::new(); + let watcher_control = + crate::state::CommunityConnectionControl::new(watcher_cancel.clone()); state.conn_manager.register( watcher, tx, ctrl, + terminal_ctrl2, None, - CancellationToken::new(), + watcher_cancel, tenant.community(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + watcher_control, ); state.sub_registry.register_scoped( tenant.community(), @@ -1835,12 +1937,16 @@ mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pubkey) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pubkey); @@ -2372,9 +2478,15 @@ mod tests { use crate::state::AppState; pub(super) fn test_config() -> crate::config::Config { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test_with_db_from_env: env-free for NIP-FI parsing + // (never races nip_fi_config tests), but picks up DATABASE_URL / + // BUZZ_TEST_DATABASE_URL so that DB-backed helpers in + // presence_storage_postgres_tests that call + // `sqlx::PgPool::connect(&state.config.database_url)` reach a real + // DB in CI. Falls back to the port-1 stub in unit-test runs. + // [F6: hermetic NIP-FI + real DB URL for postgres helpers] + let mut config = crate::config::Config::hermetic_for_test_with_db_from_env(); config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); config } @@ -2475,12 +2587,16 @@ mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); @@ -2801,12 +2917,16 @@ mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); @@ -2860,4 +2980,358 @@ mod tests { ); } } + + // ── W2 (event barrier): expiry fired mid-flight blocks persistent EVENT ingest ── + // + // Arms `before_event_ingest` — the hook immediately before `acquire_effect()` + // in the persistent EVENT path. Dispatches `handle_event` with a live gate, + // waits for the hook to signal the handler reached the permit boundary, + // fires the gate expiry (cancel), then releases the hook. The handler tries + // `acquire_effect()` and gets `SessionExpired`, returns without calling + // `ingest_event()` (no DB write, no fan-out). + // + // The mutation evidence proves the permit sits at the ingest boundary: + // A) Delete `before_event_ingest(...)` from event.rs → handler never + // stalls at the hook → cancel fires before acquire_effect (race). + // Without the hook the test is non-deterministic. + // B) Remove `acquire_effect()` from event.rs → handler calls `ingest_event` + // despite the cancel → DB write is attempted → `send_rx` gets OK(true) + // or a DB error response, NOT a "session expired" OK(false) → assertion panics. + // C) Swap the gate to off_mode → acquire_effect always succeeds after cancel + // → same as (B), assertion panics. + // + // This test also lives in `postgres_tests` (ignored, requiring real Postgres): + // the durable DB assertion and publication-counter assertion are wired there. + // See `postgres_tests::w2_event_ingest_barrier_expiry_mid_flight_blocks_persistence` + // below for the full witness including the publication oracle. + + // ── postgres_tests: W2 durable + publication oracle ─────────────────────── + // + // Selected by the `postgres-ci` nextest profile filter + // (`test(/postgres_tests::/)`) which also passes `--run-ignored ignored-only`. + // These tests require a real Postgres instance; the URL is resolved from + // `state.config.database_url` (set by `DATABASE_URL` env var in CI, same + // source `test_state()` uses — no hard-coded URL). + mod postgres_tests { + + // W2 full witness: event-ingest barrier + durable absence + publication oracle. + // + // Extends the unit-level W2 barrier test with two Postgres-required assertions: + // 1. Durable DB absence: the event row is NOT in the `events` table. + // 2. Publication oracle: `before_event_publish` counter is 0, proving + // `dispatch_persistent_event_inner` (and thus `publish_event`) was never + // called — not a proxy, the real publication boundary. + // + // Mutation evidence: + // Remove `acquire_effect()` from event.rs → ingest_event is called → + // dispatch_persistent_event_inner runs → before_event_publish fires → + // publish_count = 1 → `assert_eq!(publish_count, 0)` panics. + // AND: the row IS in the DB → COUNT(*) = 1 → DB assertion panics. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn w2_event_ingest_barrier_expiry_mid_flight_blocks_persistence() { + use std::collections::HashMap; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let key = nostr::Keys::generate(); + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = + mpsc::channel::(1); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + community, + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: key.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + // Kind:1 TextNote with no #h tag — no DB calls before before_event_ingest. + let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "w2 postgres barrier test") + .sign_with_keys(&key) + .unwrap(); + let event_id_bytes = event.id.to_bytes(); + + let state = crate::state::tests::test_state().await; + + // Register the publication counter BEFORE arming the hook, so any + // concurrent dispatch for this community is also counted. + let publish_count = + crate::nip_fi_test_hooks::event_publish_counter::register(community); + + // Arm the barrier at the persistent EVENT seam. + let (arrived_rx, release) = crate::nip_fi_test_hooks::event_ingest_hook::arm(community); + + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = tokio::spawn(async move { + super::super::handle_event(event, conn2, state2).await; + }); + + // Wait for the handler to reach before_event_ingest. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W2: handler must reach before_event_ingest within 5s") + .expect("arrived channel closed"); + + // Fire expiry: cancel so acquire_effect returns SessionExpired. + cancel.cancel(); + + // Release — handler resumes, calls acquire_effect(), gets SessionExpired. + release.notify_one(); + + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W2: handle_event must return within 5s") + .expect("handle_event task must not panic"); + + // ── Frame assertions ─────────────────────────────────────────────────── + let frame = send_rx + .try_recv() + .expect("W2: a 'session expired' OK(false) must be sent on gate denial"); + match frame { + axum::extract::ws::Message::Text(t) => { + assert!( + t.contains("session expired"), + "W2: frame must contain 'session expired'; got: {t}" + ); + assert!(t.contains("false"), "W2: frame must be OK(false); got: {t}"); + } + other => panic!("W2: expected Text frame, got {other:?}"), + } + assert!( + send_rx.try_recv().is_err(), + "W2: no additional frames must be sent after session-expired denial" + ); + + // ── Publication oracle: real publication boundary ────────────────────── + // + // `before_event_publish` fires immediately before `publish_event` in + // `dispatch_persistent_event_inner`. Zero calls proves `publish_event` + // was never reached — not a proxy, the actual publication boundary. + // + // Mutation evidence: + // Remove `acquire_effect()` → dispatch_persistent_event_inner runs → + // before_event_publish fires → publish_count = 1 → assertion panics. + let publish_attempts = publish_count.load(Ordering::Relaxed); + crate::nip_fi_test_hooks::event_publish_counter::deregister(community); + assert_eq!( + publish_attempts, 0, + "W2: publish_event must NOT be called — \ + dispatch_persistent_event_inner must not have been reached \ + when acquire_effect returns SessionExpired; \ + got {publish_attempts} publish attempt(s)" + ); + + // ── Durable DB assertion ─────────────────────────────────────────────── + // + // Requires real Postgres. Confirms the event row is absent from `events`. + // Connects to the same database `test_state()` built its pool from + // (`state.config.database_url` ← `DATABASE_URL` env var in CI). + // + // Mutation evidence: + // Remove `acquire_effect()` → ingest_event is attempted → with a real DB, + // the row IS inserted → COUNT(*) = 1 → assertion panics. + let pool = sqlx::PgPool::connect(&state.config.database_url) + .await + .expect("W2: Postgres must be reachable at state.config.database_url"); + let event_id_hex = hex::encode(event_id_bytes); + let row_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE id = decode($1, 'hex')") + .bind(&event_id_hex) + .fetch_one(&pool) + .await + .expect("W2: event row count query"); + + assert_eq!( + row_count, 0, + "W2: event row must NOT be in the DB — \ + ingest_event must not have been called when acquire_effect returns SessionExpired; \ + found {row_count} row(s)" + ); + } + } + + // ── W_observer_permit: F1 — cancel before permit acquisition blocks publication + // + // Arms `before_observer_publication` so the test can fire expiry while the + // observer handler is between owner-validation and `acquire_effect`. After + // release, `acquire_effect` returns `SessionExpired` and the handler sends + // OK(false, "restricted: session expired") without calling `mark_local_event` + // or `publish_event`. + // + // Setup: two distinct key pairs (owner and agent). The owner sends a Control + // frame to the agent. The connection authenticates as the owner via NIP-42, + // with `agent_owner_pubkey` set so `session_owner_match` short-circuits the + // DB lookup. The content is NIP-44 encrypted using `encrypt_observer_payload`. + // + // Mutation evidence: + // A) Delete the `#[cfg(test)] before_observer_publication(...)` call → + // `arrived_rx` times out → test panics. + // B) Remove `acquire_effect` from `handle_agent_observer_event` → + // handler publishes even when cancelled → sends OK(true) → panics. + // C) Change gate to `off_mode` → `acquire_effect` always succeeds after + // cancel → publishes → OK(true) → panics. + #[tokio::test] + async fn w_observer_permit_cancel_before_acquisition_blocks_publication() { + use buzz_core::observer::{ + encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, + OBSERVER_FRAME_TAG, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Two distinct keys: the owner sends a Control frame to the agent. + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pubkey = owner_keys.public_key(); + let agent_pubkey = agent_keys.public_key(); + + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xf1f1)); + + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + let subscriptions = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); + + // Connection is authenticated as the owner. `agent_owner_pubkey` is set + // to the owner's own pubkey so `session_owner_match` returns true and the + // DB lookup (is_agent_owner) is skipped. + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: owner_pubkey, + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + // session_owner_match: ctx.agent_owner_pubkey == Some(route.owner) + // where route.owner == event.pubkey == owner_pubkey. + agent_owner_pubkey: Some(owner_pubkey), + }, + )), + subscriptions: Arc::clone(&subscriptions), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let state = crate::state::tests::test_state().await; + let conn_id = conn.conn_id; + + // Build a valid Control frame: owner → agent. + // NIP-44 encrypted so content_looks_like_nip44() passes. + let encrypted = encrypt_observer_payload( + &owner_keys, + &agent_pubkey, + &serde_json::json!({"type": "cancel_turn"}), + ) + .expect("W_observer_permit: encrypt observer payload"); + let event = EventBuilder::new(Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), encrypted) + .tags([ + Tag::parse(["p", &agent_pubkey.to_hex()]).expect("p tag"), + Tag::parse([OBSERVER_AGENT_TAG, &agent_pubkey.to_hex()]).expect("agent tag"), + Tag::parse([OBSERVER_FRAME_TAG, OBSERVER_FRAME_CONTROL]).expect("frame tag"), + ]) + .sign_with_keys(&owner_keys) + .expect("W_observer_permit: sign observer event"); + let event_id_hex_str = event.id.to_hex(); + + // Arm the barrier. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::observer_publication_hook::arm(community); + + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let event_clone = event.clone(); + let eid_clone = event_id_hex_str.clone(); + let handle = tokio::spawn(async move { + super::handle_agent_observer_event(event_clone, conn_id, &eid_clone, conn2, state2) + .await + }); + + // Wait for the handler to reach the permit boundary. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W_observer_permit: handler must reach before_observer_publication within 5s") + .expect("arrived channel closed"); + + // Fire expiry and release the barrier. + cancel.cancel(); + release.notify_one(); + + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W_observer_permit: handler must return within 5s") + .expect("handle_agent_observer_event must not panic"); + + // Handler must send OK(false, "restricted: session expired"). + let resp = send_rx + .try_recv() + .expect("W_observer_permit: handler must send a message"); + let resp_text = match resp { + axum::extract::ws::Message::Text(t) => t.to_string(), + other => panic!("W_observer_permit: expected Text, got {other:?}"), + }; + assert!( + resp_text.contains("false"), + "W_observer_permit: handler must send OK(false) when gate expired; got: {resp_text}" + ); + assert!( + resp_text.contains("session expired"), + "W_observer_permit: handler must include 'session expired'; got: {resp_text}" + ); + assert!( + send_rx.try_recv().is_err(), + "W_observer_permit: exactly one message expected" + ); + } } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 97d252cb7e8..c6cdd293a64 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -267,6 +267,18 @@ pub async fn handle_req( )); return; } + // IMPORTANT 6: acquire a REQ effect permit before the search query and + // hold it through historical delivery/EOSE, just as the normal REQ branch + // does around registration/history. Without this, an authenticated frame + // can finish validation after the deadline and return history without an + // authoritative seam check. [FI-TRACE-LEASE-BOUND, NIP-50 search seam] + let _search_permit = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::closed(&sub_id, "restricted: session expired")); + return; + } + }; handle_search_req( &sub_id, &filters, @@ -282,6 +294,30 @@ pub async fn handle_req( return; } + // B2: acquire effect permit immediately before the first subscription-map + // mutation. The permit is held through map insert, sub_registry registration, + // topic retain, historical delivery, and EOSE. Off-mode: proceed + // unconditionally. [FI-TRACE-LEASE-BOUND, B2 seam: REQ registration] + // + // Test hook: fires immediately before acquire_effect. + // [nip_fi_test_hooks::req_registration_hook] + #[cfg(test)] + crate::nip_fi_test_hooks::before_req_registration(conn.tenant.community()).await; + let _req_permit = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + conn.send(RelayMessage::closed(&sub_id, "restricted: session expired")); + return; + } + }; + // Test hook: fires after permit is acquired (held) but before sub_registry + // registration. Used by F5 loopback teardown witness: admin disconnect fires + // here (permit still held → quiescence blocks expiry task) then the hook + // releases → handler registers subscription → connection epilogue removes it. + // No-op in production. [nip_fi_test_hooks::req_permit_acquired_hook, F5] + #[cfg(test)] + crate::nip_fi_test_hooks::after_req_permit_acquired(conn.tenant.community()).await; + { let mut subs = conn.subscriptions.lock().await; subs.insert(sub_id.clone(), filters.clone()); @@ -1177,7 +1213,7 @@ fn huddle_liveness_session_ids(filters: &[Filter]) -> Vec { session_ids } -async fn handle_huddle_liveness_req( +pub(crate) async fn handle_huddle_liveness_req( sub_id: &str, filters: &[Filter], parent_channel_ids: &[uuid::Uuid], @@ -1730,7 +1766,7 @@ mod tests { crate::nip11::RelayInfo::build( None, None, - false, + crate::nip11::RelayCapabilityFlags::default(), crate::config::DEFAULT_MAX_FRAME_BYTES, None, None, @@ -2541,4 +2577,401 @@ mod tests { // No #p tag — fallback required. assert!(!result_gated_count_safe_for_pushdown(&f, &owner)); } + + // ── W3: B2 REQ gate — barrier expiry mid-flight blocks subscription registration + // + // Arms `before_req_registration` — the hook immediately before `acquire_effect()` + // in the REQ registration path. Dispatches `handle_req` with a live (not-yet- + // cancelled) gate, waits for the hook to signal the handler reached the permit + // boundary, fires expiry (cancel), then releases the hook. The handler tries + // `acquire_effect()` and gets `SessionExpired`, sends CLOSED without inserting + // the subscription. + // + // Hook location: `handlers/req.rs`, immediately before `acquire_effect()`. + // + // Mutation evidence: + // A) Delete `#[cfg(test)] before_req_registration(...)` from req.rs → + // hook never fires → `arrived_rx` times out → test panics. + // B) Remove `acquire_effect()` from req.rs → handler inserts the subscription + // despite the cancelled gate → `subs.is_empty()` assertion panics. + // C) Change gate to `off_mode` → `acquire_effect()` succeeds after cancel + // → subscription IS inserted → `subs.is_empty()` assertion panics. + + async fn w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration_body() { + use nostr::{Filter, Keys}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let keys = Keys::generate(); + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + + // Live gate — NOT pre-cancelled. acquire_effect succeeds unless we fire expiry. + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + let subscriptions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: keys.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::clone(&subscriptions), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let state = crate::state::tests::test_state().await; + let sub_id = "w3-barrier-test".to_string(); + // Kind:1 (TextNote) — not p-gated — so the filter clears all pre-gate + // authorization checks and reaches the `before_req_registration` hook. + let filters = vec![Filter::new().kind(nostr::Kind::TextNote).limit(1)]; + + // Arm the barrier: fires when handle_req reaches before_req_registration. + let (arrived_rx, release) = crate::nip_fi_test_hooks::req_registration_hook::arm(community); + + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = + tokio::spawn(async move { handle_req(sub_id, filters, vec![], conn2, state2).await }); + + // Wait for the handler to reach the permit boundary. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W3: handler must reach before_req_registration within 5s") + .expect("arrived channel closed"); + + // Fire expiry: cancel so acquire_effect returns SessionExpired. + cancel.cancel(); + + // Release — handler resumes, calls acquire_effect(), gets SessionExpired. + release.notify_one(); + + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W3: handle_req must return within 5s after hook release") + .expect("handle_req task must not panic"); + + // The subscription map must be empty — the gate blocked the handler + // before any map insertion. + let subs = subscriptions.lock().await; + assert!( + subs.is_empty(), + "W3: expired gate must prevent subscription registration; subs = {subs:?}" + ); + + // A CLOSED frame must have been sent with the session-expired message. + let frame = send_rx + .try_recv() + .expect("W3: handler must send CLOSED on expired gate"); + match frame { + axum::extract::ws::Message::Text(t) => { + assert!( + t.contains("session expired"), + "W3: CLOSED message must contain 'session expired'; got: {t}" + ); + } + other => panic!("W3: expected Text CLOSED frame, got {other:?}"), + } + } + + // ── F3: liveness consumer generation equals JOIN generation in Off mode ────── + // + // Proves that `handle_huddle_liveness_req` returns the SAME generation value + // that `commit_participant_join` embeds in the 48101 JOIN event for Off-mode + // (non-mesh) rooms. Both paths use `state.huddle_liveness_generation` as their + // sole source. Desktop reconciliation clears admissions when the JOIN's embedded + // generation differs from the liveness response's generation: + // + // Desktop consumer contract (huddlePresence.ts:464-483): + // - Desktop records the JOIN's `lifecycle_generation` when it processes + // the 48101 event (`huddlePresenceRuntime.ts:264-272`). + // - On receiving a liveness KIND_HUDDLE_LIVENESS event, Desktop compares + // the event's `generation` field against the stored JOIN generation. + // - If they differ, Desktop marks the peer as "expired" and clears their + // admission state. Equal generations preserve the admission. + // + // Relay-side invariant: since both the JOIN and the liveness response read + // `state.huddle_liveness_generation.to_string()` (same UUID, same format), + // they are always equal and Desktop will never clear Off-mode admissions for + // a mismatch reason. + // + // This test proves the invariant by calling `handle_huddle_liveness_req` + // with a live Off-mode room and asserting the returned EVENT's `generation` + // matches `state.huddle_liveness_generation`. + // + // Mutation evidence: + // A) Change req.rs:1260 to use a different UUID → + // `liveness_gen != join_gen` → admission mismatch → panics. + // B) Change audio/handler.rs:716 to use a different UUID → + // same outcome from the producer side. + // + // Note: `huddle_started_links` requires DB to link session→parent channel. + // We seed a minimal DB fixture here so the liveness query finds the session. + // Runs in PG lane on wrapper DB via the thin wrapper in `postgres_tests` below. + async fn f3_liveness_consumer_generation_equals_join_generation_body() { + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let db_url = crate::test_support::required_database_url(); + let pool = sqlx::PgPool::connect(&db_url) + .await + .unwrap_or_else(|e| panic!("F3 liveness: wrapper DB {db_url} unreachable: {e}")); + + let state = crate::state::tests::test_state_with_database_pool(pool.clone()).await; + + // Seed a unique community and channels. + let community_id = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let community_uuid = community_id.as_uuid(); + let parent_channel_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let creator_key = nostr::Keys::generate(); + let creator_bytes = creator_key.public_key().to_bytes().to_vec(); + let host = format!("f3-liveness-{}.example", community_uuid.simple()); + + // Community — schema requires (id, host); no relay_pubkey column in schema.sql. + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) \ + ON CONFLICT (id) DO NOTHING", + ) + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("F3 liveness: seed community"); + + // Parent channel — channel_type enum has 'stream', 'forum', 'dm', 'workflow'. + // Conflict key is (community_id, id) per schema PRIMARY KEY. + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'f3-parent', 'stream', 'open', $3) \ + ON CONFLICT (community_id, id) DO NOTHING", + ) + .bind(parent_channel_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("F3 liveness: seed parent channel"); + + // Audio session channel (ephemeral_channel_id in the huddle_started link). + // `huddle_started_links` JOINs on backing.created_by = start.pubkey, so + // the channel's created_by MUST equal creator_bytes. + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'f3-session', 'stream', 'open', $3) \ + ON CONFLICT (community_id, id) DO NOTHING", + ) + .bind(session_id) + .bind(community_uuid) + .bind(&creator_bytes) + .execute(&pool) + .await + .expect("F3 liveness: seed session channel"); + + // Huddle-started link event (kind KIND_HUDDLE_STARTED = 48100) linking + // parent → session. `huddle_started_links` filters on: + // start.channel_id = parent_channel_id (the REQ's parent_channel_ids) + // start.kind = KIND_HUDDLE_STARTED + // content::json ->> 'ephemeral_channel_id' = session_id::text + // start.pubkey = backing.created_by (creator_bytes) + // Use the named constant — no arithmetic. + let link_content = + serde_json::json!({ "ephemeral_channel_id": session_id.to_string() }).to_string(); + let link_event_id = Uuid::new_v4().as_bytes().to_vec(); // 16-byte dummy id; BYTEA, no size constraint + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id) \ + VALUES ($1, $2, $3, NOW(), $4, '[]', $5, $6, $7) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_uuid) + .bind(&link_event_id[..]) + .bind(&creator_bytes) + .bind(buzz_core::kind::KIND_HUDDLE_STARTED as i32) + .bind(&link_content) + .bind(vec![0u8; 64]) // 64-byte dummy sig + .bind(parent_channel_id) + .execute(&pool) + .await + .expect("F3 liveness: seed KIND_HUDDLE_STARTED link event"); + + // Register a non-empty audio room so handle_huddle_liveness_req finds + // the session alive and returns the Off-mode generation. + let room = state.audio_rooms.get_or_create(community_id, session_id); + let _ = room.add_peer(creator_key.public_key().to_hex(), 1); + + // Expected generation: what both the JOIN producer and liveness consumer use. + // Production path: audio/handler.rs → `"generation": lifecycle_generation` + // where lifecycle_generation = state.huddle_liveness_generation.to_string() + // Liveness path: handlers/req.rs:1260 → state.huddle_liveness_generation.to_string() + let expected_generation = state.huddle_liveness_generation.to_string(); + + // Build a minimal ConnectionState for the liveness call. + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()); + let (send_tx, mut send_rx) = mpsc::channel::(16); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel::(1); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved(community_id, host), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: creator_key.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + // Build a liveness filter: kind=KIND_HUDDLE_LIVENESS with #d=session_id. + // This is the filter Desktop sends to get liveness events for a specific session. + let d_tag_value = session_id.to_string(); + let liveness_filter: nostr::Filter = serde_json::from_value(serde_json::json!({ + "kinds": [KIND_HUDDLE_LIVENESS], + "#d": [d_tag_value], + })) + .expect("F3 liveness: build liveness filter"); + + // Call the production liveness handler directly (same function handle_req + // calls for liveness-only filters). + handle_huddle_liveness_req( + "f3-liveness-sub", + &[liveness_filter], + &[parent_channel_id], + &conn, + &state, + ) + .await; + + // Collect all frames sent (up to EOSE). + let mut liveness_gen: Option = None; + loop { + match send_rx.try_recv() { + Ok(axum::extract::ws::Message::Text(t)) => { + let v: serde_json::Value = + serde_json::from_str(&t).expect("F3 liveness: parse frame"); + // RelayMessage::event: ["EVENT", sub_id, {...event...}] + if v[0].as_str() == Some("EVENT") { + let content: serde_json::Value = + serde_json::from_str(v[2]["content"].as_str().unwrap_or("{}")) + .unwrap_or_default(); + if let Some(gen) = content["generation"].as_str() { + liveness_gen = Some(gen.to_string()); + } + } + // RelayMessage::eose: ["EOSE", sub_id] + if v[0].as_str() == Some("EOSE") { + break; + } + } + Ok(_) => {} + Err(_) => break, + } + } + + // The liveness EVENT is required. The fixture seeds a valid KIND_HUDDLE_STARTED + // link event (kind 48100), a live room with a peer, and schema-correct community/ + // channel rows — handle_huddle_liveness_req must return an EVENT. + // A missing EVENT is a fixture or production defect, not an acceptable pass. + // + // Desktop consumer contract (huddlePresence.ts:464-483): + // Desktop records the JOIN's `generation` field, then on each liveness + // KIND_HUDDLE_LIVENESS response compares `event.content.generation` + // against the stored value. A mismatch clears the peer's admission. + // Both paths use state.huddle_liveness_generation.to_string() — they + // must be equal to preserve Off-mode admissions. + // + // Mutation evidence: + // A) Change req.rs:1260 to use a different UUID → + // `gen != expected_generation` → panics. + // B) Change audio/handler.rs commit content key from "generation" to + // anything else → Desktop cannot find the field → Desktop sees null + // vs the liveness generation → clears admissions. + let gen = liveness_gen.expect( + "F3 liveness: handle_huddle_liveness_req must return a liveness EVENT — \ + the fixture seeds a valid community (host), schema-correct stream channels, \ + a KIND_HUDDLE_STARTED link event, and a live room with a peer. \ + A missing EVENT means the fixture seed failed or the handler has a bug. \ + Mutation A: remove KIND_HUDDLE_STARTED link → no EVENT → panics here (correct). \ + Mutation B: change req.rs:1260 generation source → wrong generation → \ + assert_eq below panics.", + ); + assert_eq!( + gen, expected_generation, + "F3 liveness: liveness response generation ({gen}) must equal \ + state.huddle_liveness_generation ({expected_generation}). \ + Both paths use the same source field — a mismatch here means Desktop \ + would clear admissions for Off-mode sessions. \ + Mutation: change req.rs:1260 to a different UUID → gen != expected → panics." + ); + } + + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration() { + super::w3_b2_req_barrier_expiry_mid_flight_blocks_subscription_registration_body() + .await; + } + + /// F3: liveness response generation equals JOIN-embedded generation for Off-mode rooms. + /// + /// Proves the relay-side invariant that both `commit_participant_join` + /// (audio/handler.rs) and `handle_huddle_liveness_req` (req.rs:1260) + /// read `state.huddle_liveness_generation.to_string()` — so Desktop never + /// clears Off-mode admissions due to a generation mismatch. + /// + /// Desktop consumer contract: `huddlePresence.ts:464-483` clears admissions + /// when JOIN's `lifecycle_generation` differs from the liveness response's + /// `generation` field. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn f3_liveness_consumer_generation_equals_join_generation() { + super::f3_liveness_consumer_generation_equals_join_generation_body().await; + } + } } diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 18ea187fc7d..caea99c1ab0 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -6,6 +6,15 @@ mod admission; mod build_info; mod rejection; +/// NIP-FI session admission gate — per-connection effect-permit and quiescence barrier. +pub(crate) mod nip_fi_gate; +pub(crate) mod nip_fi_session; +/// NIP-FI test hooks — production barriers for deterministic B1/B2 witnesses. +#[cfg(test)] +pub(crate) mod nip_fi_test_hooks; +/// NIP-FI assertion validation at WebSocket upgrade. +pub(crate) mod nip_fi_upgrade; + /// REST API route handlers. pub mod api; /// WebSocket audio relay for huddle voice channels. @@ -33,6 +42,10 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// NIP-FI relay-level configuration (S4): issuer registry, JWKS, and +/// admin-command fields. Parsed from `BUZZ_NIP_FI_MODE` and +/// `BUZZ_NIP_FI_ISSUERS` at startup. +pub(crate) mod nip_fi_config; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 114b51ee7f2..953d655c914 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -481,6 +481,12 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let pubsub_for_conn_ctrl = Arc::clone(&pubsub); tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); + // Spawn Redis pub/sub subscriber for NIP-FI cross-pod disconnect commands. + // Remote pods publish to this global channel after accepting a disconnect + // command; every pod merges the deny entry and closes matching sessions. + let pubsub_for_nip_fi = Arc::clone(&pubsub); + tokio::spawn(async move { pubsub_for_nip_fi.run_nip_fi_disconnect_subscriber().await }); + let auth = AuthService::new(config.auth.clone()); // Postgres FTS: the searchable row IS the persisted event row (its @@ -514,7 +520,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?; info!("Media storage connected"); - let (app_state, audit_shutdown) = AppState::new( + let (mut app_state, audit_shutdown) = AppState::new( config.clone(), db, redis_health_pool, @@ -526,6 +532,33 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { relay_keypair, media_storage, ); + // NIP-FI S4: construct deny map + command verifier from startup config, + // before Arc::new so we can mutate app_state directly. + // Delegates to install_nip_fi_command_components which owns JWKS warmup, + // the refresh loop, build_nip_fi_command_components, and both state assignments. + { + let nip_fi = &config.nip_fi; + if let Some(key_source) = app_state.nip_fi_jwks_source.clone() { + // Share the exact Arc that nip_fi_verifier already holds so warmup + // and the background refresh loop populate the same snapshot cache + // that assertion verification reads synchronously via key_set(). + buzz_relay::api::nip_fi::install_nip_fi_command_components( + &mut app_state, + nip_fi.mode, + &nip_fi.registry, + key_source, + &nip_fi.jwks_configs, + &nip_fi.command_configs, + ) + .await + .map_err(|e| anyhow::anyhow!("NIP-FI startup failed: {e}"))?; + } else if nip_fi.is_enforce() { + return Err(anyhow::anyhow!( + "NIP-FI: failed to construct JWKS key source \ + (empty or duplicate issuer config)" + )); + } + } let state = Arc::new(app_state); // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the @@ -1087,6 +1120,42 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { }); } + // Cross-pod NIP-FI disconnect consumer: receive deny entries from remote + // pods, merge them into the local deny map (same max(until) rule), and + // close any matching sessions. Every pod subscribes; the publishing pod + // also receives its own message and applies it — this is idempotent because + // the deny entry was already inserted locally before the publish. + // + // The consumer delegates to `apply_nip_fi_disconnect` which owns all + // validation, merge, and session-close logic. This keeps the loop body + // minimal and makes the exact production path testable end-to-end. + { + let state_for_nip_fi = Arc::clone(&state); + let mut rx = state_for_nip_fi.pubsub.subscribe_nip_fi_disconnect(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(msg) => { + let now = chrono::Utc::now(); + buzz_relay::api::nip_fi::apply_nip_fi_disconnect( + &state_for_nip_fi, + &msg, + now, + ); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("buzz_nip_fi_disconnect_lag_total").increment(n); + tracing::warn!("NIP-FI disconnect consumer lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::error!("NIP-FI disconnect broadcast channel closed"); + break; + } + } + } + }); + } + let router = build_router(Arc::clone(&state)); let health_router = build_health_router(Arc::clone(&state)); @@ -1493,6 +1562,7 @@ async fn serve( .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; uds_handle.abort(); hard_shutdown.abort(); + // The JWKS refresh loop exits via the shutting_down flag set by AppState::shutdown(). return Ok(()); } @@ -1517,6 +1587,7 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; hard_shutdown.abort(); + // The JWKS refresh loop exits via the shutting_down flag set by AppState::shutdown(). Ok(()) } diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index cd7c427c72e..e59d5f03b79 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -530,7 +530,7 @@ mod tests { /// ever reached Redis this test would hang/fail. #[tokio::test] async fn mesh_off_boots_nothing() { - let mut config = crate::config::Config::from_env().expect("default config loads"); + let mut config = crate::config::Config::hermetic_for_test(); config.mesh.enabled = false; let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") // unroutable .create_pool(Some(deadpool_redis::Runtime::Tokio1)) @@ -552,12 +552,12 @@ mod tests { /// OFF — an env-untouched image upgrade must not bind or write Redis. #[test] fn mesh_defaults_off_when_env_absent() { - // `Config::from_env` in the test env has no BUZZ_MESH set unless a - // caller exported it; assert the fail-safe reading. + // hermetic_for_test is env-free; mesh defaults to disabled. + // Guard against any caller that force-sets BUZZ_MESH=on in the environment. if std::env::var("BUZZ_MESH").is_ok() { return; // externally forced — skip rather than assert a lie } - let config = crate::config::Config::from_env().expect("default config loads"); + let config = crate::config::Config::hermetic_for_test(); assert!(!config.mesh.enabled, "BUZZ_MESH absent must mean mesh off"); } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 043458b4bca..b4e0a0c14a3 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -70,6 +70,10 @@ pub struct RelayInfo { /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, + /// NIP-FI federated identity capability descriptor. + /// Absent when the relay is in `Off` mode. [FI-TRACE-DISCOVERY-PRIVATE] + #[serde(skip_serializing_if = "Option::is_none")] + pub federated_identity: Option, } /// Public capability descriptor for relay-proxied GIF search. @@ -105,6 +109,10 @@ pub struct RelayLimitation { pub payment_required: bool, /// Whether writes are restricted to authorized pubkeys. pub restricted_writes: bool, + /// Whether NIP-FI federated identity assertions are required at upgrade. + /// Advertised `true` when the relay is in `Enforce` mode. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub federated_identity: bool, /// NIP-ER: how the relay delivers due reminders ("push" or "lazy"). #[serde(skip_serializing_if = "Option::is_none")] pub due_delivery_mode: Option, @@ -124,7 +132,7 @@ pub struct RelayLimitation { /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token /// toggle (`config.require_auth_token`). -fn relay_limitation(max_message_length: usize) -> RelayLimitation { +fn relay_limitation(max_message_length: usize, advertise_fi: bool) -> RelayLimitation { let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA") .ok() .and_then(|v| v.parse().ok()) @@ -140,11 +148,25 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { auth_required: true, payment_required: false, restricted_writes: true, + federated_identity: advertise_fi, due_delivery_mode: Some("push".to_string()), max_not_before_delta: Some(max_not_before_delta), } } +/// Build-time capability flags for [`RelayInfo::build`]. +/// +/// Grouping the boolean capability flags gives `build` a named seam for +/// protocol advertisement decisions and drops the argument count below the +/// clippy threshold. +#[derive(Default, Clone, Copy)] +pub(crate) struct RelayCapabilityFlags { + /// Whether NIP-43 (relay membership) is advertised in `supported_nips`. + pub advertise_nip43: bool, + /// Whether NIP-FI (federated identity) is advertised. + pub advertise_fi: bool, +} + impl RelayInfo { /// Builds the relay's NIP-11 information document. /// @@ -173,15 +195,19 @@ impl RelayInfo { /// `build` advertises the provider-agnostic `buzz-gif` extension and the /// relay-relative metadata search endpoint. It must never contain a /// provider credential. - pub fn build( + pub(crate) fn build( relay_self: Option<&str>, icon: Option<&str>, - advertise_nip43: bool, + flags: RelayCapabilityFlags, max_message_length: usize, pairing_relay_url: Option<&str>, admin_api: Option<&str>, gif_provider: Option<&str>, ) -> Self { + let RelayCapabilityFlags { + advertise_nip43, + advertise_fi, + } = flags; debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -202,6 +228,20 @@ impl RelayInfo { } }); + // NIP-FI discovery descriptor. Per [FI-TRACE-DISCOVERY-PRIVATE], the + // document is byte-identical across all enrollment modes — no issuer + // URLs, audiences, claim names, or per-tenant details. Only the + // capability fact (core transport profile + freshness class) is public. + let federated_identity = advertise_fi.then(|| { + serde_json::json!({ + "core": "client-attached", + "assertion_freshness": { + "class": "offline-jwt", + "maximum_residual_upstream_revocation_seconds": null + } + }) + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -214,11 +254,12 @@ impl RelayInfo { push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - limitation: Some(relay_limitation(max_message_length)), + limitation: Some(relay_limitation(max_message_length, advertise_fi)), pairing_relay_url: pairing_relay_url.map(str::to_string), admin_api: admin_api.map(str::to_string), gif, relay_self: relay_self.map(|s| s.to_string()), + federated_identity, } } } @@ -288,10 +329,14 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; let admin_api = admin_api_advertisement(state.config.admin.as_ref()); + let advertise_fi = state.config.nip_fi.is_enforce(); let mut info = RelayInfo::build( relay_self.as_deref(), icon.as_deref(), - advertise_nip43, + RelayCapabilityFlags { + advertise_nip43, + advertise_fi, + }, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), admin_api.as_deref(), @@ -404,7 +449,7 @@ fn admin_api_advertisement(admin: Option<&crate::config::AdminConfig>) -> Option const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( Option<&str>, Option<&str>, - bool, + RelayCapabilityFlags, usize, Option<&str>, Option<&str>, @@ -463,7 +508,15 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -472,7 +525,7 @@ mod tests { let info = RelayInfo::build( None, None, - false, + RelayCapabilityFlags::default(), DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), None, @@ -485,7 +538,15 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } @@ -495,7 +556,7 @@ mod tests { let info = RelayInfo::build( None, None, - false, + RelayCapabilityFlags::default(), DEFAULT_MAX_FRAME_BYTES, None, None, @@ -512,8 +573,15 @@ mod tests { .contains(&serde_json::json!("buzz-gif"))); assert!(!json.to_string().contains("api_key")); - let unconfigured = - RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let unconfigured = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); assert!(unconfigured.gif.is_none()); assert!(!unconfigured .supported_extensions @@ -529,7 +597,7 @@ mod tests { let info = RelayInfo::build( None, Some("data:image/webp;base64,UklGRg=="), - false, + RelayCapabilityFlags::default(), DEFAULT_MAX_FRAME_BYTES, None, None, @@ -546,8 +614,15 @@ mod tests { ); for icon in [None, Some("")] { - let info = - RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let info = RelayInfo::build( + None, + icon, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -562,12 +637,20 @@ mod tests { // REQ, EVENT, and COUNT all unconditionally require // `AuthState::Authenticated` (see `crates/buzz-relay/src/handlers/`), // so the NIP-11 doc must advertise it. - assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES).auth_required); + assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES, false).auth_required); } #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None, None, None); + let info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + 262_144, + None, + None, + None, + ); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -598,7 +681,15 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -614,7 +705,7 @@ mod tests { let info = RelayInfo::build( Some(pk), None, - false, + RelayCapabilityFlags::default(), DEFAULT_MAX_FRAME_BYTES, None, None, @@ -631,7 +722,10 @@ mod tests { let info = RelayInfo::build( Some(pk), None, - true, + RelayCapabilityFlags { + advertise_nip43: true, + advertise_fi: false, + }, DEFAULT_MAX_FRAME_BYTES, None, None, @@ -647,7 +741,18 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let _ = RelayInfo::build( + None, + None, + RelayCapabilityFlags { + advertise_nip43: true, + advertise_fi: false, + }, + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); } fn admin_config(host: &str) -> crate::config::AdminConfig { @@ -664,7 +769,15 @@ mod tests { fn admin_api_absent_when_admin_surface_not_configured() { assert_eq!(admin_api_advertisement(None), None); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None, None); + let info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); assert!(info.admin_api.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -684,7 +797,7 @@ mod tests { let info = RelayInfo::build( None, None, - false, + RelayCapabilityFlags::default(), DEFAULT_MAX_FRAME_BYTES, None, advertised.as_deref(), diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs new file mode 100644 index 00000000000..ed311303a7d --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -0,0 +1,700 @@ +//! NIP-FI relay-level configuration: issuer set, JWKS warm/refresh, and +//! S4 admin-command fields. +//! +//! Parses `BUZZ_NIP_FI_MODE` and `BUZZ_NIP_FI_ISSUERS` from the environment. +//! S3 fields (assertion-level per-issuer config) build the `IssuerRegistry` + +//! `IssuerJwksConfig` slice for `ProductionJwksSource`. S4 fields +//! (`maximum_command_age_seconds`, `authorized_principals`, +//! `deny_set_capacity`) are parsed alongside so a single JSON array drives both +//! tiers. +//! +//! # Environment variables +//! +//! | Variable | Required | Description | +//! |---|---|---| +//! | `BUZZ_NIP_FI_MODE` | No | `off` (default), `enforce`, or `deny_protected`. | +//! | `BUZZ_NIP_FI_ISSUERS` | If enforce | JSON array of issuer configs. | +//! | `BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS` | If enforce | Per-partition session-lifetime bound. | +//! +//! Missing or empty `BUZZ_NIP_FI_MODE` defaults to `off`. + +use buzz_auth::{ + validate_nip_fi_config, FreshnessClass, IssuerJwksConfig, IssuerPolicy, IssuerPolicyError, + IssuerRegistry, JwksSourceContract, JwtAlgorithm, NipFiMode, NipFiStartupError, TokenClass, +}; + +use crate::config::ConfigError; + +/// Maximum accepted `max_connection_lifetime` in seconds (30 days). +const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; + +// ── Per-issuer JSON config ──────────────────────────────────────────────────── + +/// One entry in the `BUZZ_NIP_FI_ISSUERS` JSON array. +/// +/// Contains both assertion-level fields (S3) and optional command-API fields +/// (S4). All S4 fields default to absent. +/// +/// **Minimal S4 example** (assertion enforcement + command API): +/// ```json +/// [ +/// { +/// "issuer": "https://idp.example.com", +/// "audiences": ["https://relay.example.com"], +/// "token_class": "nip-fi+jwt", +/// "algorithms": ["ES256"], +/// "skew_seconds": 30, +/// "maximum_assertion_age_seconds": 3600, +/// "jwks_uri": "https://idp.example.com/.well-known/jwks.json", +/// "jwks_refresh_interval_seconds": 300, +/// "jwks_hard_deadline_seconds": 86400, +/// "maximum_command_age_seconds": 30, +/// "authorized_principals": ["admin@idp.example.com"], +/// "deny_set_capacity": 50000 +/// } +/// ] +/// ``` +#[derive(Debug, serde::Deserialize)] +pub(super) struct IssuerEnvConfig { + // ── S3 assertion fields ─────────────────────────────────────────────── + /// Exact `iss` value. + pub issuer: String, + /// One or more accepted `aud` values. + pub audiences: Vec, + /// `"nip-fi+jwt"` (only supported class in this version). + pub token_class: TokenClassEnvConfig, + /// Algorithm names, e.g. `["ES256"]`. + pub algorithms: Vec, + /// Accepted clock skew in seconds (≤ 300). + #[serde(default)] + pub skew_seconds: u64, + /// `iat + maximum_assertion_age` residual bound in seconds. + pub maximum_assertion_age_seconds: u64, + /// HTTPS endpoint serving the JWK Set for this issuer. + pub jwks_uri: String, + /// Seconds between JWKS refreshes. + pub jwks_refresh_interval_seconds: u64, + /// Hard deadline for accepting a JWKS snapshot in seconds. + pub jwks_hard_deadline_seconds: u64, + + // ── S4 command-API fields (all optional) ────────────────────────────── + /// Maximum command JWT age in seconds; `0 < x ≤ 60`. Required to enable + /// the disconnect API for this issuer. + #[serde(default)] + pub maximum_command_age_seconds: Option, + /// Non-empty list of authorized `sub` values. Required when + /// `maximum_command_age_seconds` is set. + #[serde(default)] + pub authorized_principals: Option>, + /// Hard ceiling on live deny entries for this issuer. Defaults to + /// [`crate::api::nip_fi::DEFAULT_DENY_SET_CAPACITY`] when absent. + #[serde(default)] + pub deny_set_capacity: Option, +} + +/// Token-class discriminant in the issuer config JSON. +#[derive(Debug, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) enum TokenClassEnvConfig { + #[serde(rename = "nip-fi+jwt")] + DedicatedNipFi, + #[serde(rename = "at+jwt")] + AccessTokenAtJwt, +} + +// ── NipFiRelayConfig ────────────────────────────────────────────────────────── + +/// The relay-level NIP-FI configuration produced by [`NipFiRelayConfig::from_env`]. +/// +/// Carries the validated `NipFiMode`, the full `IssuerRegistry`, the parallel +/// `IssuerJwksConfig` slice, and the per-issuer S4 command entries. +#[derive(Debug, Clone)] +pub struct NipFiRelayConfig { + /// The enforcement mode. + pub mode: NipFiMode, + /// Validated per-issuer assertion-policy registry. + pub registry: IssuerRegistry, + /// Parallel JWKS configs for `ProductionJwksSource`. + pub jwks_configs: Vec, + /// Hard upper bound on a single connection lease, in seconds. + /// `0` when mode is Off/DenyProtected (sentinel: use [`Self::max_connection_lifetime`]). + pub max_connection_lifetime_secs: u64, + /// Per-issuer S4 command entries: `(issuer_uri, CommandIssuerEnvConfig)`. + /// Empty when mode is Off/DenyProtected or no issuer has command fields. + pub command_configs: Vec<(String, crate::api::nip_fi::CommandIssuerEnvConfig)>, +} + +impl NipFiRelayConfig { + /// Parse NIP-FI relay configuration from the process environment. + /// + /// Returns `Err` when `BUZZ_NIP_FI_MODE=enforce` but required config is + /// missing or invalid (fail-closed). + pub fn from_env() -> Result { + let mode = parse_mode()?; + + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(Self { + mode, + registry: IssuerRegistry::new(), + jwks_configs: Vec::new(), + max_connection_lifetime_secs: 0, + command_configs: Vec::new(), + }); + } + + // Enforce mode: BUZZ_NIP_FI_ISSUERS and lifetime are required. + let issuers_json = std::env::var("BUZZ_NIP_FI_ISSUERS").map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but BUZZ_NIP_FI_ISSUERS is not set; \ + set it to a JSON array of issuer configs" + .to_string(), + ) + })?; + if issuers_json.trim().is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must not be empty in enforce mode".to_string(), + )); + } + + let issuer_entries: Vec = + serde_json::from_str(&issuers_json).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS could not be parsed (line {}, column {})", + e.line(), + e.column() + )) + })?; + + if issuer_entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must contain at least one issuer in enforce mode".to_string(), + )); + } + + let max_connection_lifetime_secs = parse_u64_bounded( + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + 1, + MAX_CONNECTION_LIFETIME_SECS, + )? + .ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but \ + BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS is not set; \ + every enforce deployment must configure a positive finite value" + .to_string(), + ) + })?; + + let mut registry = IssuerRegistry::new(); + let mut jwks_configs = Vec::with_capacity(issuer_entries.len()); + let mut command_configs = Vec::new(); + + for entry in &issuer_entries { + let idx = jwks_configs.len(); // 0-based issuer index for error messages + let (policy, jwks_config) = build_issuer(entry).map_err(|e| { + ConfigError::InvalidValue(format!("BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: {e}")) + })?; + registry.insert(policy); + jwks_configs.push(jwks_config); + + // Extract S4 command fields if present. + if let Some(cmd_age) = entry.maximum_command_age_seconds { + let principals = entry.authorized_principals.clone().unwrap_or_default(); + // Malformed S4 fields in enforce mode must reject startup. + if principals.is_empty() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + maximum_command_age_seconds is set but authorized_principals is \ + absent or empty — command API requires at least one authorized principal" + ))); + } + if cmd_age == 0 || cmd_age > 60 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + maximum_command_age_seconds must be in [1, 60]; got {cmd_age}" + ))); + } + let capacity = entry + .deny_set_capacity + .unwrap_or(crate::api::nip_fi::DEFAULT_DENY_SET_CAPACITY); + if capacity == 0 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + deny_set_capacity must be positive (non-zero)" + ))); + } + // Validate that CommandIssuerPolicy can be constructed — this is the + // same gate the builder uses, so a startup rejection here is tight. + crate::api::nip_fi::validate_command_issuer_config( + idx, + cmd_age, + &principals, + capacity, + ) + .map_err(ConfigError::InvalidValue)?; + command_configs.push(( + entry.issuer.clone(), + crate::api::nip_fi::CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(cmd_age), + authorized_principals: Some(principals), + deny_set_capacity: entry.deny_set_capacity, + }, + )); + } else { + // No maximum_command_age_seconds: in enforce mode every issuer MUST be + // command-capable (NIP-FI.md:405-409 requires maximum_command_age per + // authorized issuer). An enforce issuer without command fields would + // silently produce an empty command_configs and a permanently-503 + // endpoint — reject it at startup. + // + // Orphan S4 fields are detected first to give the operator precise + // error feedback before the all-or-nothing rejection fires. + if entry.authorized_principals.is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + authorized_principals is set but maximum_command_age_seconds is absent — \ + S4 command API requires maximum_command_age_seconds" + ))); + } + if entry.deny_set_capacity.is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + deny_set_capacity is set but maximum_command_age_seconds is absent — \ + S4 command API requires maximum_command_age_seconds" + ))); + } + // No orphan fields: reject because enforce mode requires every issuer + // to be command-capable (NIP-FI.md:405-409). + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {idx}]: \ + maximum_command_age_seconds is required in enforce mode — \ + every configured issuer must be command-capable. \ + Add maximum_command_age_seconds and authorized_principals, \ + or remove this issuer from BUZZ_NIP_FI_ISSUERS" + ))); + } + } + + // Delegate final validation to buzz-auth startup gate. + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks_configs).map_err( + |e: NipFiStartupError| ConfigError::InvalidValue(format!("NIP-FI config invalid: {e}")), + )?; + + Ok(Self { + mode, + registry, + jwks_configs, + max_connection_lifetime_secs, + command_configs, + }) + } + + /// Returns the configured `max_connection_lifetime` as a `Duration`. + /// Returns `None` in `Off`/`DenyProtected` mode. + pub fn max_connection_lifetime(&self) -> Option { + if self.max_connection_lifetime_secs == 0 { + None + } else { + Some(std::time::Duration::from_secs( + self.max_connection_lifetime_secs, + )) + } + } + + /// Returns `true` when the relay is in `Enforce` mode. + pub fn is_enforce(&self) -> bool { + matches!(self.mode, NipFiMode::Enforce) + } + + /// Construct an Off-mode config with no environment reads. + /// + /// Used by `Config::hermetic_for_test()` to avoid racing against NIP-FI + /// env-mutating tests in the same process. Only available in test builds. + #[cfg(test)] + pub(crate) fn off_for_test() -> Self { + Self { + mode: NipFiMode::Off, + registry: IssuerRegistry::new(), + jwks_configs: Vec::new(), + max_connection_lifetime_secs: 0, + command_configs: Vec::new(), + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn parse_mode() -> Result { + match std::env::var("BUZZ_NIP_FI_MODE") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "" | "off" => Ok(NipFiMode::Off), + "enforce" => Ok(NipFiMode::Enforce), + "deny_protected" => Ok(NipFiMode::DenyProtected), + other => Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_MODE must be 'off', 'enforce', or 'deny_protected'; got {other:?}" + ))), + } +} + +fn parse_algorithm(s: &str) -> Result { + match s { + "RS256" => Ok(JwtAlgorithm::RS256), + "RS384" => Ok(JwtAlgorithm::RS384), + "RS512" => Ok(JwtAlgorithm::RS512), + "ES256" => Ok(JwtAlgorithm::ES256), + "ES384" => Ok(JwtAlgorithm::ES384), + "PS256" => Ok(JwtAlgorithm::PS256), + "PS384" => Ok(JwtAlgorithm::PS384), + "PS512" => Ok(JwtAlgorithm::PS512), + "EdDSA" => Ok(JwtAlgorithm::EdDSA), + other => Err(format!("unknown or non-asymmetric algorithm {other:?}")), + } +} + +fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConfig), String> { + let algorithms: Vec = entry + .algorithms + .iter() + .map(|s| parse_algorithm(s)) + .collect::>()?; + + let token_class = match entry.token_class { + TokenClassEnvConfig::DedicatedNipFi => TokenClass::DedicatedNipFi, + TokenClassEnvConfig::AccessTokenAtJwt => { + return Err("\"at+jwt\" token class requires a subject-class contract; \ + use \"nip-fi+jwt\" for initial deployments" + .to_string()); + } + }; + + let jwks_contract = JwksSourceContract::new( + entry.jwks_uri.clone(), + entry.jwks_refresh_interval_seconds, + entry.jwks_hard_deadline_seconds, + ) + .ok_or_else(|| { + "invalid JWKS source contract (check jwks_uri is HTTPS, \ + refresh_interval < hard_deadline, and both are positive)" + .to_string() + })?; + + let policy = IssuerPolicy::new( + entry.issuer.clone(), + entry.audiences.clone(), + token_class, + FreshnessClass::OfflineJwt, + algorithms, + entry.skew_seconds, + entry.maximum_assertion_age_seconds, + None, // offline-jwt: no status age + jwks_contract.clone(), + ) + .map_err(|e: IssuerPolicyError| e.to_string())?; + + let jwks_config = IssuerJwksConfig { + issuer: entry.issuer.clone(), + contract: jwks_contract, + }; + + Ok((policy, jwks_config)) +} + +fn parse_u64_bounded(var: &str, min: u64, max: u64) -> Result, ConfigError> { + match std::env::var(var) { + Ok(val) => { + let n: u64 = val.parse().map_err(|_| { + ConfigError::InvalidValue(format!("{var} must be a positive integer; got {val:?}")) + })?; + if n < min || n > max { + return Err(ConfigError::InvalidValue(format!( + "{var} must be between {min} and {max}; got {n}" + ))); + } + Ok(Some(n)) + } + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!( + "{var} contains invalid UTF-8" + ))), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // Use the crate-wide env-var serialization mutex so NIP-FI env-mutation + // tests here serialize against Config::from_env callers in config.rs and + // api/gifs.rs (which also call from_env in the same process). [F6] + use crate::config::ENV_TEST_MUTEX as ENV_LOCK; + + /// RAII guard: removes env vars on drop to keep tests isolated. + struct EnvGuard(Vec<&'static str>); + impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + Self(keys.to_vec()) + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.0 { + std::env::remove_var(key); + } + } + } + + const NIP_FI_VARS: &[&str] = &[ + "BUZZ_NIP_FI_MODE", + "BUZZ_NIP_FI_ISSUERS", + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + ]; + + // ── Mode parsing ────────────────────────────────────────────────────────── + + #[test] + fn off_mode_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::remove_var("BUZZ_NIP_FI_MODE"); + let cfg = NipFiRelayConfig::from_env().expect("Off mode must not fail"); + assert!(matches!(cfg.mode, NipFiMode::Off)); + assert!(cfg.registry.is_empty()); + } + + #[test] + fn deny_protected_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "deny_protected"); + let cfg = NipFiRelayConfig::from_env().expect("DenyProtected must not fail"); + assert!(matches!(cfg.mode, NipFiMode::DenyProtected)); + } + + #[test] + fn unknown_mode_is_rejected() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "permissive"); + let err = NipFiRelayConfig::from_env().expect_err("unknown mode must error"); + assert!(err.to_string().contains("BUZZ_NIP_FI_MODE")); + } + + #[test] + fn enforce_without_issuers_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::remove_var("BUZZ_NIP_FI_ISSUERS"); + let err = NipFiRelayConfig::from_env() + .expect_err("enforce without issuers must be a config error"); + assert!(err.to_string().contains("BUZZ_NIP_FI_ISSUERS")); + } + + #[test] + fn enforce_command_age_without_principals_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // issuer has command age but no principals + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30 + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("command age without principals must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("authorized_principals"), + "error names the missing field: {msg}" + ); + } + + #[test] + fn orphan_authorized_principals_without_command_age_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // authorized_principals without maximum_command_age_seconds — orphan field. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "authorized_principals": ["admin@idp.example.com"] + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("orphan authorized_principals must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("authorized_principals"), + "error names the orphan field: {msg}" + ); + assert!( + msg.contains("maximum_command_age_seconds"), + "error names the missing dependency: {msg}" + ); + } + + #[test] + fn orphan_deny_set_capacity_without_command_age_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // deny_set_capacity without maximum_command_age_seconds — orphan field. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "deny_set_capacity": 1000 + }]"#, + ); + let err = + NipFiRelayConfig::from_env().expect_err("orphan deny_set_capacity must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("deny_set_capacity"), + "error names the orphan field: {msg}" + ); + assert!( + msg.contains("maximum_command_age_seconds"), + "error names the missing dependency: {msg}" + ); + } + + #[test] + fn enforce_issuer_without_command_fields_is_rejected() { + // An enforce-mode issuer entry with ALL THREE S4 fields absent must + // fail startup. This is the blocker-4a case: the issuer is a valid + // JWKS/assertion issuer but carries no command config. Without this + // rejection from_env() would succeed with an empty command_configs, + // the endpoint would permanently return 503, and startup would log nothing. + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // All three S4 command fields absent — pure assertion/JWKS issuer. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400 + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("enforce issuer without command fields must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("maximum_command_age_seconds"), + "error must name the missing field: {msg}" + ); + } + + // ── Privacy: config error must not expose sensitive values ──────────────── + // + // Verifies that a malformed BUZZ_NIP_FI_ISSUERS whose authorized_principals + // contains a sensitive email address does NOT appear in the error message. + // + // Mandatory red: restoring the raw serde interpolation (`{e}`) exposes the + // sentinel value in the Display output and fails this test. + + #[test] + fn config_error_does_not_expose_sensitive_principal_value() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // A syntactically broken JSON object that contains a sensitive sentinel + // where authorized_principals would be. The `INVALID_TYPE_HERE` string + // is not valid JSON for the Vec field — serde will produce a + // type error that in a naive `{e}` interpolation would include the raw + // string, potentially exposing the surrounding value. + const SENTINEL: &str = "admin+private-sentinel@example.invalid"; + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // The authorized_principals field is a string instead of an array, + // which causes serde to emit a type-error that typically includes the + // supplied value when formatted with `{e}` (the bug we are guarding). + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + format!( + r#"[{{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30, + "authorized_principals": "{SENTINEL}" + }}]"# + ), + ); + + let err = NipFiRelayConfig::from_env().expect_err("malformed issuers must fail"); + let display_msg = err.to_string(); + let debug_msg = format!("{err:?}"); + + // Safe category must be present. + assert!( + display_msg.contains("BUZZ_NIP_FI_ISSUERS could not be parsed"), + "Display message must contain the safe category string: {display_msg}" + ); + // Sentinel must NOT appear in any user-facing output path. + assert!( + !display_msg.contains(SENTINEL), + "Display message must NOT contain the sensitive sentinel: {display_msg}" + ); + assert!( + !debug_msg.contains(SENTINEL), + "Debug output must NOT contain the sensitive sentinel: {debug_msg}" + ); + } +} diff --git a/crates/buzz-relay/src/nip_fi_gate.rs b/crates/buzz-relay/src/nip_fi_gate.rs new file mode 100644 index 00000000000..c3a78842603 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_gate.rs @@ -0,0 +1,480 @@ +//! `SessionAdmissionGate` — per-connection lifetime authority for NIP-FI sessions. +//! +//! Every WS connection that carries a NIP-FI assertion gets one gate. The gate +//! owns three orthogonal concerns: +//! +//! * **Effect permit**: any handler that performs an irreversible side effect +//! (AUTH state commit, EVENT persistence, REQ subscription registration, +//! COUNT query, `48101` commit) must acquire a [`SessionEffectPermit`] before +//! the first irreversible operation. The permit is a `Tokio` fair read lock +//! guard — expiry cannot start until all pre-expiry permits are dropped. +//! +//! * **Expiry**: at the session deadline, [`SessionAdmissionGate::expire`] +//! queues the terminal denial frame, cancels the socket immediately, then +//! acquires the write guard to record [`SessionPhase::Expired`]. Acquiring the +//! write guard blocks until all outstanding read guards (live effect permits) +//! are dropped, making the lock a quiescence barrier: post-expiry teardown +//! (subscription removal, peer cleanup) cannot start until all permitted +//! effects have finished. +//! +//! * **Deadline check**: `acquire_effect` checks cancellation AND the wall-clock +//! deadline *under* the read guard, so a permit can never be obtained after +//! expiry has been queued or the deadline has passed. +//! +//! ## Ordering guarantees +//! +//! ```text +//! expire() : terminal() → cancel.cancel() → write guard → Expired +//! acquire() : obtain read guard → check cancel/deadline → Ok(permit) or Err +//! ``` +//! +//! An effect holding a permit before `cancel.cancel()` fires **wins**: the +//! permit prevents the write guard, and the effect may complete its bounded +//! commit/fan-out. An effect that cannot obtain a permit after `cancel.cancel()` +//! **loses**: the cancel check inside the read guard fails, and the effect is +//! rejected before any side effect occurs. +//! +//! ## Off-mode +//! +//! When `deadline` is `None`, `acquire_effect` always succeeds (no cancel is ever +//! issued by the gate itself, and `None` deadline is treated as infinite). The +//! gate has zero overhead in off-mode: one arc read per effect acquire. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; +use tokio_util::sync::CancellationToken; + +// ── Phase ───────────────────────────────────────────────────────────────────── + +/// Connection phase from the gate's perspective. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionPhase { + Active, + Expired, +} + +// ── Permit ──────────────────────────────────────────────────────────────────── + +/// A live effect permit. While this value is held, expiry cannot transition to +/// `Expired` — the read lock prevents the write guard in `expire()`. +/// +/// Drop the permit as soon as the effect's irreversible work is done. Holding it +/// across long-lived awaits that are not part of the bounded effect is incorrect. +#[must_use = "effect permit must be held through the bounded effect and then dropped"] +#[cfg_attr(test, derive(Debug))] +pub(crate) struct SessionEffectPermit { + /// Holds the Tokio read lock, keeping expiry from transitioning until drop. + _guard: OwnedRwLockReadGuard, +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +/// Returned by [`SessionAdmissionGate::acquire_effect`] when the session has +/// already expired or the deadline has passed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SessionExpired; + +// ── Gate ───────────────────────────────────────────────────────────────────── + +/// Per-connection session lifetime authority. +/// +/// Create one per WS connection via [`SessionAdmissionGate::new`] (with a +/// deadline) or [`SessionAdmissionGate::off_mode`] (no deadline, never expires +/// on its own). Root and audio connections use the same type. +#[derive(Debug)] +pub(crate) struct SessionAdmissionGate { + /// UTC deadline after which new effect permits are rejected. + /// + /// `None` means off-mode: no deadline, gate never self-expires. + pub deadline: Option>, + phase: Arc>, + cancel: CancellationToken, +} + +impl SessionAdmissionGate { + /// Create a gate with the given deadline. + pub(crate) fn new(deadline: DateTime, cancel: CancellationToken) -> Arc { + Arc::new(Self { + deadline: Some(deadline), + phase: Arc::new(RwLock::new(SessionPhase::Active)), + cancel, + }) + } + + /// Create an off-mode gate (no deadline, never self-expires). + #[allow(dead_code)] // used in nip_fi_gate unit tests and forthcoming B1/B2 witnesses + pub(crate) fn off_mode(cancel: CancellationToken) -> Arc { + Arc::new(Self { + deadline: None, + phase: Arc::new(RwLock::new(SessionPhase::Active)), + cancel, + }) + } + + /// Acquire an effect permit. + /// + /// Obtains the fair Tokio read lock, then checks: + /// 1. `cancel.is_cancelled()` — expiry has already been queued. + /// 2. `deadline` is past (equality is expired). + /// + /// Returns `Ok(SessionEffectPermit)` only when both checks pass. + /// Returns `Err(SessionExpired)` otherwise, without performing any side effect. + pub(crate) async fn acquire_effect( + self: &Arc, + ) -> Result { + // Obtain the fair read lock. This blocks if expiry holds the write guard + // (quiescence window) but that is bounded — expire() holds the write guard + // only long enough to set the phase field. + let guard = Arc::clone(&self.phase).read_owned().await; + + // Check cancellation and deadline under the read guard. Once we hold the + // guard, expiry cannot transition until we release it. A cancelled token + // or a past deadline means expiry has already been queued (or is guaranteed + // to fire before any new socket I/O completes). + if self.cancel.is_cancelled() { + return Err(SessionExpired); + } + if let Some(deadline) = self.deadline { + // Equality is expired per spec [FI-TRACE-LEASE-BOUND]. + if Utc::now() >= deadline { + return Err(SessionExpired); + } + } + + Ok(SessionEffectPermit { _guard: guard }) + } + + /// Returns a future that resolves when the gate's cancellation token fires. + /// + /// Use in `tokio::select!` to exit early when the connection closes from + /// outside the expiry path (e.g., the client disconnects before the deadline). + pub(crate) fn cancelled(&self) -> tokio_util::sync::WaitForCancellationFuture<'_> { + self.cancel.cancelled() + } + + /// Cheaply test whether the session is expired or past its deadline. + /// + /// This is a **defense-in-depth** check at dispatch time, not a substitute + /// for acquiring a permit. Handler permits are authoritative; this check + /// merely avoids spawning obviously-dead work. + #[allow(dead_code)] // used in nip_fi_gate unit tests and forthcoming B1/B2 witnesses + pub(crate) fn is_expired_or_past_deadline(&self) -> bool { + if self.cancel.is_cancelled() { + return true; + } + if let Some(deadline) = self.deadline { + if Utc::now() >= deadline { + return true; + } + } + false + } + + /// Expire the session. + /// + /// Ordering (per contract): + /// 1. Call `terminal()` — queues the denial frame before any lock is held. + /// Socket cancellation starts immediately; the send loop delivers the + /// terminal frame and then `Close`. + /// 2. Call `cancel.cancel()` — socket termination starts at the deadline; + /// never waits for any permit. + /// 3. Acquire the write guard — blocks until all outstanding read guards + /// (live effect permits) are dropped. This is the **quiescence barrier**: + /// teardown cannot start until all pre-expiry effects have finished their + /// bounded commits. + /// 4. Record `SessionPhase::Expired`. + /// 5. Release the write guard — the expiry task's `await` on this call + /// completes, and the task returns. Connection teardown (which awaits the + /// expiry task handle) then proceeds. + /// + /// `terminal` is called exactly once, before any lock is held, so it cannot + /// deadlock and cannot be delayed by in-flight permits. + pub(crate) async fn expire(&self, terminal: impl FnOnce()) { + // Step 1: queue the denial frame (terminal delivery, no lock held). + terminal(); + // Step 2: cancel the socket immediately — never waits for a permit. + self.cancel.cancel(); + // Steps 3–5: quiescence barrier. + let mut phase = self.phase.write().await; + *phase = SessionPhase::Expired; + // Write guard released here on drop — expiry task's await completes. + } + + /// Quiesce the session after an external cancellation. + /// + /// Called when the connection was cancelled externally (e.g., admin disconnect + /// via `disconnect_nip_fi`) before the session deadline. In this path the + /// socket is already cancelled; this method only acquires the write guard to + /// wait for any in-flight effect permits to be released. + /// + /// ## Why this is needed — F5 + /// + /// `spawn_nip_fi_expiry_task` uses a `tokio::select!` with two arms: + /// 1. `sleep(remaining)` — fires at deadline; calls `gate.expire()`, which + /// performs the full quiescence barrier before the task exits. + /// 2. `gate.cancelled()` — fires on external cancellation; previously returned + /// immediately without waiting for in-flight permits. + /// + /// When admin disconnect fires cancel and the cancellation arm wins, the + /// connection teardown (`remove_connection`, subscription cleanup) in + /// `connection.rs` awaits the expiry task — but the task returned before + /// any in-flight REQ handler (which holds a `SessionEffectPermit`) finished + /// its registration. The handler then completed registration AFTER cleanup, + /// leaving orphan subscription registry entries and retained topic references. + /// + /// With `quiesce()`, the cancellation arm blocks until every outstanding + /// effect permit is dropped, guaranteeing that all permitted work finishes + /// before the task returns — and therefore before `remove_connection` runs. + /// + /// ## Ordering contract + /// + /// * Does NOT call `terminal()` — the denial frame is already enqueued by + /// the `manager_disconnect_nip_fi` or `disconnect_nip_fi` path that + /// cancelled the token. A second frame would be a protocol error. + /// * Does NOT call `cancel.cancel()` — the token is already cancelled. + /// * Records `SessionPhase::Expired` so that any `acquire_effect` that races + /// the write guard (after the cancel check fails) sees the correct phase. + /// + /// [F5: admin-cancel quiescence barrier] + pub(crate) async fn quiesce(&self) { + // Acquire the write guard — blocks until all outstanding read guards + // (live effect permits) are dropped. This is the same quiescence barrier + // used by expire(), minus the terminal() and cancel.cancel() calls that + // have already happened. + let mut phase = self.phase.write().await; + *phase = SessionPhase::Expired; + // Write guard released here — expiry task's await completes. + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::sync::CancellationToken; + + fn gate_with_far_deadline() -> Arc { + let cancel = CancellationToken::new(); + let deadline = Utc::now() + chrono::Duration::hours(1); + SessionAdmissionGate::new(deadline, cancel) + } + + // ── acquire_effect passes in normal operation ────────────────────────────── + + #[tokio::test] + async fn acquire_effect_succeeds_when_active_and_within_deadline() { + let gate = gate_with_far_deadline(); + let permit = gate.acquire_effect().await; + assert!( + permit.is_ok(), + "acquire_effect must succeed when gate is active and deadline is in the future" + ); + } + + // ── cancel causes acquire_effect to fail ────────────────────────────────── + + #[tokio::test] + async fn acquire_effect_fails_after_cancel() { + let cancel = CancellationToken::new(); + let gate = + SessionAdmissionGate::new(Utc::now() + chrono::Duration::hours(1), cancel.clone()); + cancel.cancel(); + let result = gate.acquire_effect().await; + assert!( + matches!(result, Err(SessionExpired)), + "acquire_effect must return Err(SessionExpired) after cancel" + ); + } + + // ── past deadline causes acquire_effect to fail ────────────────────────── + + #[tokio::test] + async fn acquire_effect_fails_when_past_deadline() { + let cancel = CancellationToken::new(); + let past = Utc::now() - chrono::Duration::seconds(1); + let gate = SessionAdmissionGate::new(past, cancel); + let result = gate.acquire_effect().await; + assert!( + matches!(result, Err(SessionExpired)), + "acquire_effect must return Err(SessionExpired) when deadline has passed" + ); + } + + // ── off-mode gate never self-cancels ────────────────────────────────────── + + #[tokio::test] + async fn off_mode_gate_always_succeeds() { + let cancel = CancellationToken::new(); + let gate = SessionAdmissionGate::off_mode(cancel); + let permit = gate.acquire_effect().await; + assert!( + permit.is_ok(), + "off-mode gate must always grant permits when cancel has not fired" + ); + } + + // ── expire() ordering: terminal fires before cancel, write guard acquired after ── + + #[tokio::test] + async fn expire_calls_terminal_then_cancels_then_quiesces() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + let cancel = CancellationToken::new(); + let gate = + SessionAdmissionGate::new(Utc::now() + chrono::Duration::hours(1), cancel.clone()); + + let sequence = StdArc::new(AtomicUsize::new(0)); + + // Hold a permit — expire() must block on the write guard until we drop it. + let permit = gate.acquire_effect().await.expect("permit before expiry"); + + let gate2 = Arc::clone(&gate); + let seq2 = StdArc::clone(&sequence); + let seq3 = StdArc::clone(&sequence); + let expire_task = tokio::spawn(async move { + gate2 + .expire(|| { + // terminal() fires before cancel.cancel() and before write guard. + seq2.fetch_add(1, Ordering::SeqCst); // step 1 + }) + .await; + seq3.fetch_add(10, Ordering::SeqCst); // step 3 (after write guard released) + }); + + // Yield so expire_task can start and reach the write guard wait. + for _ in 0..5 { + tokio::task::yield_now().await; + } + + // expire_task should have called terminal() (seq += 1) and cancel.cancel() + // but be blocked on the write guard (seq should be 1, not 11). + assert!( + cancel.is_cancelled(), + "cancel must fire before the write guard is acquired" + ); + let seq_before_drop = sequence.load(Ordering::SeqCst); + assert_eq!( + seq_before_drop, 1, + "terminal() must have run (seq=1) but write guard must not yet be released (seq<11)" + ); + + // Drop the permit — expire_task can now obtain the write guard. + drop(permit); + + tokio::time::timeout(std::time::Duration::from_secs(2), expire_task) + .await + .expect("expire must complete within timeout") + .expect("expire task must not panic"); + + assert_eq!( + sequence.load(Ordering::SeqCst), + 11, + "expire must complete fully after permit is dropped (seq = 1 + 10 = 11)" + ); + + // After expiry, no new permit can be obtained. + let post_expire = gate.acquire_effect().await; + assert!( + matches!(post_expire, Err(SessionExpired)), + "acquire_effect must fail after expire() completes" + ); + } + + // ── is_expired_or_past_deadline ─────────────────────────────────────────── + + #[tokio::test] + async fn is_expired_false_when_active() { + let gate = gate_with_far_deadline(); + assert!( + !gate.is_expired_or_past_deadline(), + "active gate must not report expired" + ); + } + + #[tokio::test] + async fn is_expired_true_after_cancel() { + let cancel = CancellationToken::new(); + let gate = + SessionAdmissionGate::new(Utc::now() + chrono::Duration::hours(1), cancel.clone()); + cancel.cancel(); + assert!( + gate.is_expired_or_past_deadline(), + "cancelled gate must report expired" + ); + } +} + +// ── quiesce() blocks until all held permits drop ─────────────────────────── +// +// Proves that `quiesce()` does not complete until all outstanding read guards +// (live effect permits) are released. This is the F5 quiescence barrier: +// admin-cancel fires, expiry task's cancellation arm calls quiesce(), and the +// task must not return (allowing `remove_connection` to run) until every +// pre-cancel permit-holder has finished its bounded effect. +// +// Mutation evidence: +// A) Remove the write-guard acquisition from `quiesce()` (make it a no-op) → +// quiesce completes before the permit is dropped → `quiesce_done` is +// true before `drop(permit)` → "quiesce must not complete" assertion panics. +// B) Remove the `quiesce().await` call from the cancellation arm of +// `spawn_nip_fi_expiry_task` and return immediately → +// the task exits before the permit is released → same assertion panics +// at the integration level (W_f5 witness in nip_fi_session tests). + +#[tokio::test] +async fn quiesce_blocks_until_held_permits_drop() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc as StdArc; + + let cancel = CancellationToken::new(); + let gate = SessionAdmissionGate::new(Utc::now() + chrono::Duration::hours(1), cancel.clone()); + + // Hold a permit — quiesce must block until we release it. + let permit = gate.acquire_effect().await.expect("permit before cancel"); + + // Cancel the token (simulating admin disconnect). + cancel.cancel(); + + let quiesce_done = StdArc::new(AtomicBool::new(false)); + let quiesce_done2 = StdArc::clone(&quiesce_done); + let gate2 = Arc::clone(&gate); + + let quiesce_task = tokio::spawn(async move { + gate2.quiesce().await; + quiesce_done2.store(true, Ordering::SeqCst); + }); + + // Yield to let quiesce_task start — it should be blocked on the write guard. + for _ in 0..5 { + tokio::task::yield_now().await; + } + + assert!( + !quiesce_done.load(Ordering::SeqCst), + "quiesce must not complete while a permit is still held" + ); + + // Drop the permit — quiesce_task can now acquire the write guard. + drop(permit); + + tokio::time::timeout(std::time::Duration::from_secs(2), quiesce_task) + .await + .expect("quiesce must complete within timeout after permit drop") + .expect("quiesce task must not panic"); + + assert!( + quiesce_done.load(Ordering::SeqCst), + "quiesce must complete after permit is released" + ); + + // After quiesce, acquire_effect must fail (phase is Expired). + let post_quiesce = gate.acquire_effect().await; + assert!( + matches!(post_quiesce, Err(SessionExpired)), + "acquire_effect must fail after quiesce() (phase = Expired)" + ); +} diff --git a/crates/buzz-relay/src/nip_fi_session.rs b/crates/buzz-relay/src/nip_fi_session.rs new file mode 100644 index 00000000000..c0a11ad476c --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -0,0 +1,1349 @@ +//! Shared NIP-FI post-upgrade session seams. +//! +//! This module owns: +//! +//! * [`NipFiWsRoute`] — route discriminant for frame construction and logging. +//! * [`enforce_nip_fi_key_pairing`] — the single production function that owns +//! the full NIP-FI key-pairing verdict, denial frame delivery, metric, +//! auth-state transition (Root), and cancellation for both ingresses. +//! * [`spawn_nip_fi_expiry_task`] — the shared session-lifetime enforcement +//! constructor used by both root and audio routes. +//! * [`authorization_denied_frame`] — route-specific frame builder used by +//! both the pairing seam and the expiry seam. +//! +//! **Invariant**: both production call sites call `enforce_nip_fi_key_pairing` +//! and `spawn_nip_fi_expiry_task` from this module; no caller may re-implement +//! these side effects. + +use axum::extract::ws::Message as WsMessage; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::warn; +use uuid::Uuid; + +use crate::connection::ConnectionState; + +// ── Route discriminant ──────────────────────────────────────────────────────── + +/// Which ingress a session is on. Governs denial frame format and log labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NipFiWsRoute { + Root, + Audio, +} + +// ── Pairing seam ────────────────────────────────────────────────────────────── + +/// Outcome of [`enforce_nip_fi_key_pairing`]. +/// +/// Callers MUST return immediately on `Denied`; all denial side-effects have +/// already been performed inside the function. +#[must_use] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PairingOutcome { + Paired, + Denied, +} + +/// Route-specific resources needed to deliver the pairing denial. +pub(crate) enum PairingDenialTarget<'a> { + Root(&'a ConnectionState), + Audio { + ws_send: &'a mut futures_util::stream::SplitSink< + axum::extract::ws::WebSocket, + axum::extract::ws::Message, + >, + cancel: &'a CancellationToken, + channel_id: Uuid, + }, +} + +/// Enforce the NIP-FI key-pairing invariant [FI-INV-05]. +/// +/// When an assertion was presented at upgrade, the proven NIP-42 key MUST equal +/// the assertion's `nostr_pubkey` claim; a claimless assertion is also a denial. +/// +/// This function owns the **entire denial path**: verdict, route-specific denial +/// frame delivery, `buzz_auth_failures_total{reason="nip_fi_key_mismatch"}`, +/// a route-labelled warning (no `iss`/`sub`/raw-assertion fields), auth-state +/// transition (Root only), and cancellation. Callers must not repeat any of +/// those effects. +/// +/// Returns [`PairingOutcome::Paired`] when: +/// * no assertion is present (off-mode), or +/// * the assertion's `nostr_pubkey` claim matches `proven_pubkey`. +/// +/// Returns [`PairingOutcome::Denied`] after performing all denial side-effects. +pub(crate) async fn enforce_nip_fi_key_pairing( + assertion: Option<&buzz_auth::VerifiedAssertion>, + proven_pubkey: nostr::PublicKey, + target: PairingDenialTarget<'_>, +) -> PairingOutcome { + // No assertion → off-mode; pass unconditionally. + let Some(assertion) = assertion else { + return PairingOutcome::Paired; + }; + + // Matching key → pass. + if matches!(assertion.asserted_key(), Some(k) if k == proven_pubkey) { + return PairingOutcome::Paired; + } + + // Mismatch or claimless assertion — single shared denial branch. + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "nip_fi_key_mismatch" + ) + .increment(1); + + match target { + PairingDenialTarget::Root(conn) => { + warn!( + conn_id = %conn.conn_id, + route = "root", + proven_pubkey = %proven_pubkey.to_hex(), + "NIP-FI key pairing mismatch — closing connection" + ); + *conn.auth_state.lock().unwrap() = crate::connection::AuthState::Failed; + // Serialize through the terminal-transition lock: reason publication + + // winner-only frame enqueue happen while the lock is held, so a + // concurrent disconnect_community cannot fire cancel.cancel() before + // the winning payload is enqueued. The auth_state write uses the sync + // StdMutex and completes before acquiring the sync transition lock. + // [FI-TRACE-CANCEL-RACE] + conn.community_control + .pairing_deny_terminal(&conn.terminal_ctrl_tx, NipFiWsRoute::Root); + conn.cancel.cancel(); + } + PairingDenialTarget::Audio { + ws_send, + cancel, + channel_id, + } => { + warn!( + %channel_id, + route = "audio", + proven_pubkey = %proven_pubkey.to_hex(), + "NIP-FI key pairing mismatch — closing connection" + ); + use futures_util::SinkExt as _; + // Bounded sends: pre-writer audio exits must not block on a stalled + // sink indefinitely. Same 1-second terminal/close policy as every + // other pre-writer error exit. [R2: bounded I/O invariant] + let _deny_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let _ = tokio::time::timeout_at( + _deny_deadline, + ws_send.send(authorization_denied_frame(NipFiWsRoute::Audio)), + ) + .await; + // Send explicit 1008 POLICY close frame before dropping. The audio + // handler owns ws_send directly here (send_loop not yet started). + // [FI-TRACE-CLOSE-CODE] + let _ = tokio::time::timeout_at( + _deny_deadline, + ws_send.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: axum::extract::ws::Utf8Bytes::from_static("authorization denied"), + }))), + ) + .await; + cancel.cancel(); + } + } + + PairingOutcome::Denied +} + +// ── Shared frame constructor ─────────────────────────────────────────────────── + +/// Build the exact NIP-FI authorization-denied frame for the given route. +/// +/// * Root: a Nostr NOTICE — `["NOTICE","restricted: authorization denied"]`. +/// * Audio: `{"type":"restricted","message":"restricted: authorization denied"}`. +pub(crate) fn authorization_denied_frame(route: NipFiWsRoute) -> WsMessage { + use buzz_auth::DenialClass; + let text = DenialClass::AuthorizationDenied.nostr_text(); + WsMessage::Text(match route { + NipFiWsRoute::Root => crate::protocol::RelayMessage::notice(text).into(), + NipFiWsRoute::Audio => serde_json::json!({"type": "restricted", "message": text}) + .to_string() + .into(), + }) +} + +// ── Shared expiry task constructor ──────────────────────────────────────────── + +/// Spawn the NIP-FI session-lifetime enforcement task for either route. +/// +/// At `deadline`, the task: +/// 1. Calls `gate.expire(terminal)` with the route-specific terminal closure. +/// Inside `gate.expire()`: +/// a. The terminal closure sets `deny_reason_tx` to `AuthorizationDenied` +/// (so the send loop's cancel branch emits a 1008 POLICY close frame), +/// enqueues the denial frame on `terminal_ctrl_tx`, and increments the +/// lease-expiration metric. [FI-TRACE-CLOSE-CODE] +/// b. `cancel.cancel()` — socket termination starts immediately. +/// c. The gate acquires the write guard (quiescence barrier) — blocks until +/// all outstanding effect permits are released, then records `Expired`. +/// 2. The task then returns, allowing connection teardown to proceed. +/// +/// Equality at deadline is expired; already-expired deadlines fire immediately. +/// No in-band renewal is added. [FI-TRACE-LEASE-BOUND] +pub(crate) fn spawn_nip_fi_expiry_task( + deadline: chrono::DateTime, + gate: std::sync::Arc, + terminal_ctrl_tx: mpsc::Sender, + route: NipFiWsRoute, + control: crate::state::CommunityConnectionControl, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let now = chrono::Utc::now(); + // Equality at deadline is expired: strict less-than. + let remaining = if now < deadline { + (deadline - now) + .to_std() + .unwrap_or(std::time::Duration::ZERO) + } else { + std::time::Duration::ZERO + }; + tokio::select! { + _ = tokio::time::sleep(remaining) => { + // F4 elapsed-deadline schedule witness: fires AFTER sleep (wall-clock + // past deadline) but BEFORE gate.expire() (cancel not yet set). + // This lets the test confirm that acquire_effect() returns + // SessionExpired via the deadline arm (nip_fi_gate.rs:139-142), + // not the cancellation arm (136-137). No-op in production. + // [nip_fi_session::expiry_pre_expire_hook, F4] + #[cfg(test)] + expiry_pre_expire_hook::fire(control.hook_key).await; + // gate.expire() ordering (per contract [6d3b75a5]): + // 1. terminal() — queues denial frame before any lock is held. + // 2. cancel.cancel() — socket termination at the deadline. + // 3. write guard — quiescence barrier; blocks until all pre-expiry + // effect permits are released, then records Expired. + // The task's await on gate.expire() completes only after the write + // guard is released, so connection teardown (which awaits this task + // handle before remove_connection) cannot start until pre-expiry + // effects have finished their bounded commits. + gate.expire(|| { + // Acquire the transition lock so a concurrent disconnect_community + // that loses reason publication cannot fire cancel.cancel() until + // this call's winning try_send completes. Without the lock, + // community's cancel could wake the consumer before the denial + // frame is enqueued — reproducing the original close-only defect + // for an expiry/delete race. [FI-TRACE-CANCEL-RACE] + control.expiry_deny_terminal(&terminal_ctrl_tx, route); + metrics::counter!("buzz_nip_fi_lease_expirations_total").increment(1); + warn!( + route = ?route, + "NIP-FI session lease expired — closing connection" + ); + }) + .await; + } + _ = gate.cancelled() => { + // F5: admin-cancel quiescence barrier. + // + // When cancel fires from outside (admin disconnect, deny-set scan, + // or community delete), the denial frame has already been enqueued + // by the caller that cancelled the token. We must NOT produce a + // second terminal frame — so we do NOT call expire() here. + // + // However, we DO need to wait for any in-flight effect permits + // before the task returns. The connection teardown in connection.rs + // awaits this task handle before running remove_connection and + // deregister. Without quiesce(), a REQ handler that acquired a + // permit (read guard) before the cancel can finish its sub_registry + // registration AFTER teardown has already run remove_connection — + // leaving orphan subscription entries and retained topic references + // that accumulate across repeated admin-cancel/REQ-resume cycles. + // + // gate.quiesce() acquires the write guard, blocking until all + // outstanding permits are released, then records Expired — the + // same quiescence barrier used by expire(), without the terminal + // or cancel calls. [FI-TRACE-LEASE-BOUND, F5] + // + // Signal the test hook BEFORE quiesce() so the test can observe + // the task reaching the quiescence wait point — the explicit + // rendezvous that replaces the 50 ms timing window. No-op in + // production. [F5: quiesce-entry rendezvous] + #[cfg(test)] + quiesce_cancel_arm_hook::fire(control.hook_key); + gate.quiesce().await; + } + } + }) +} + +// ── Test-only hook: quiesce-entry rendezvous ────────────────────────────────── +// +// Fires a oneshot in the cancel arm of `spawn_nip_fi_expiry_task` immediately +// before `gate.quiesce().await`. The test awaits this signal to know that the +// task has entered the cancel arm and is blocked at the quiescence barrier — +// replacing the 50 ms timing window with an explicit rendezvous. +// +// Keyed by `control.hook_key` (per-control UUID) so concurrent tests never share +// a slot. Zero-cost in production: the module is `#[cfg(test)]` only. +#[cfg(test)] +pub(crate) mod quiesce_cancel_arm_hook { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + use tokio::sync::oneshot; + use uuid::Uuid; + + static HOOKS: OnceLock>>> = OnceLock::new(); + + fn hook_map() -> &'static Mutex>> { + HOOKS.get_or_init(|| Mutex::new(HashMap::new())) + } + + /// Arm a one-shot signal for `key`. Returns the receiver the test awaits. + pub(crate) fn arm(key: Uuid) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + hook_map().lock().unwrap().insert(key, tx); + rx + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + #[allow(dead_code)] + pub(crate) fn disarm(key: Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `spawn_nip_fi_expiry_task` in the cancel arm before quiesce(). + /// No-op when not armed for this key. + pub(crate) fn fire(key: Uuid) { + let tx = hook_map().lock().unwrap().remove(&key); + if let Some(t) = tx { + let _ = t.send(()); + } + } +} + +// ── Test-only hook: expiry-task pre-expire rendezvous (F4 elapsed-deadline) ── +// +// Fires a two-phase gate in the sleep arm of `spawn_nip_fi_expiry_task`, +// AFTER `tokio::time::sleep(remaining)` completes (wall-clock deadline elapsed) +// but BEFORE `gate.expire()` is called (cancel token not yet set). +// +// This creates the exact precondition for exercising the elapsed-deadline arm +// at `nip_fi_gate.rs:139-142`: +// - `Utc::now() >= deadline` — true: sleep fired after real wall-clock time +// - `cancel.is_cancelled()` — false: gate.expire() hasn't called cancel.cancel() yet +// +// Keyed by `control.hook_key` (per-control UUID) so concurrent tests never share +// a slot. Zero-cost in production: the module is `#[cfg(test)]` only. +#[cfg(test)] +pub(crate) mod expiry_pre_expire_hook { + use std::collections::HashMap; + use std::sync::{Arc, Mutex, OnceLock}; + use tokio::sync::{oneshot, Notify}; + use uuid::Uuid; + + struct Gate { + arrived: oneshot::Sender<()>, + release: Arc, + } + + static HOOKS: OnceLock>> = OnceLock::new(); + + fn hook_map() -> &'static Mutex> { + HOOKS.get_or_init(|| Mutex::new(HashMap::new())) + } + + /// Arm a two-phase barrier for `key`. + /// + /// Returns `(arrived_rx, release)`. Await `arrived_rx` to know when the + /// expiry task has paused before `gate.expire()`; call `release.notify_one()` + /// to let it proceed. + pub(crate) fn arm(key: Uuid) -> (oneshot::Receiver<()>, Arc) { + let (tx, rx) = oneshot::channel(); + let release = Arc::new(Notify::new()); + hook_map().lock().unwrap().insert( + key, + Gate { + arrived: tx, + release: release.clone(), + }, + ); + (rx, release) + } + + /// Disarm the hook for `key` (clean up if test completes before expiry fires). + #[allow(dead_code)] + pub(crate) fn disarm(key: Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `spawn_nip_fi_expiry_task` in the sleep arm before gate.expire(). + /// Signals arrived, then waits for release. No-op when not armed. + pub(crate) async fn fire(key: Uuid) { + let gate = hook_map().lock().unwrap().remove(&key); + if let Some(g) = gate { + let _ = g.arrived.send(()); + g.release.notified().await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use nostr::Keys; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // ── B3: terminal denial frame survives saturated ctrl_tx ────────────────── + // + // Root pairing and expiry both write the denial frame to `terminal_ctrl_tx` + // (capacity 1) instead of `ctrl_tx` (capacity 8). These tests saturate + // ctrl_tx completely, then fire the denial path and assert the frame arrives + // on the terminal channel regardless. + // + // Mutation evidence: + // A) Switch `enforce_nip_fi_key_pairing` back to `ctrl_tx.try_send` → + // terminal_rx is empty → recv assertion panics. + // B) Switch `spawn_nip_fi_expiry_task` back to `ctrl_tx.try_send` → + // terminal_rx is empty → recv assertion panics. + + #[tokio::test] + async fn b3_root_pairing_denial_delivered_when_ctrl_queue_saturated() { + let keys = Keys::generate(); + let deadline = Utc::now() + chrono::Duration::hours(1); + let assertion = + buzz_auth::VerifiedAssertion::for_test(Some(keys.public_key()), vec![deadline]); + + let (send_tx, _send_rx) = mpsc::channel(4); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_rx) = mpsc::channel::(1); + + // Saturate ctrl_tx to capacity 8. + for i in 0..8u8 { + ctrl_tx + .try_send(WsMessage::Text(format!("ordinary-{i}").into())) + .expect("ctrl_tx has capacity 8"); + } + assert!( + ctrl_tx + .try_send(WsMessage::Text("overflow".into())) + .is_err(), + "ctrl_tx must be full before the test exercises the denial path" + ); + + let b3_cancel = CancellationToken::new(); + let b3_control = crate::state::CommunityConnectionControl::new(b3_cancel.clone()); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::nil()), + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Pending { + challenge: "test-challenge".to_string(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: b3_cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(b3_cancel.clone()), + community_control: b3_control, + }); + // Use a different key as the proven pubkey → forced mismatch. + let wrong_pubkey = Keys::generate().public_key(); + let outcome = enforce_nip_fi_key_pairing( + conn.nip_fi_assertion.as_ref(), + wrong_pubkey, + PairingDenialTarget::Root(conn.as_ref()), + ) + .await; + + assert_eq!(outcome, PairingOutcome::Denied, "mismatch must be Denied"); + assert!( + conn.cancel.is_cancelled(), + "cancel must be called on denial" + ); + + // Terminal channel must have the denial frame despite ctrl_tx being full. + let frame = terminal_rx + .try_recv() + .expect("denial frame must arrive on terminal channel even when ctrl_tx is full"); + match frame { + WsMessage::Text(t) => { + let v: serde_json::Value = + serde_json::from_str(&t).expect("denial frame is valid JSON"); + assert!( + v.get(1) + .and_then(|c| c.as_str()) + .map(|s| s.contains("authorization denied")) + .unwrap_or(false), + "root denial frame must contain 'authorization denied': {t}" + ); + } + other => panic!("expected Text denial frame, got {other:?}"), + } + } + + #[tokio::test] + async fn b3_expiry_denial_delivered_when_ctrl_queue_saturated() { + // Saturate a separate ctrl channel to prove the expiry task doesn't + // depend on it being available. + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + for i in 0..8u8 { + ctrl_tx + .try_send(WsMessage::Text(format!("ordinary-{i}").into())) + .expect("ctrl_tx has capacity 8"); + } + drop(ctrl_tx); // expiry task never touches ctrl_tx; drop proves it + + let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + let already_expired = Utc::now() - chrono::Duration::seconds(1); + + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(already_expired, cancel.clone()); + // Build a minimal control for the expiry task — its transition lock + // serializes the terminal enqueue vs. concurrent community deletes. + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let handle = spawn_nip_fi_expiry_task( + already_expired, + gate, + terminal_tx, + NipFiWsRoute::Root, + control, + ); + handle.await.expect("expiry task must complete"); + + assert!( + cancel.is_cancelled(), + "cancel must be called by expiry task" + ); + + // Terminal channel must have the denial frame. + let frame = terminal_rx + .try_recv() + .expect("expiry denial frame must be in terminal channel"); + match frame { + WsMessage::Text(t) => { + assert!( + t.contains("authorization denied"), + "expiry denial frame must contain 'authorization denied': {t}" + ); + } + other => panic!("expected Text denial frame, got {other:?}"), + } + } + + // ── W_f5: admin-cancel arm waits for held permits before task exit ──────── + // + // Proves that when the expiry task's cancellation arm fires (external admin + // cancel), the task calls `gate.quiesce()` and does NOT return until all + // outstanding effect permits are released. + // + // This is the F5 integration-level witness. Connection teardown in + // `connection.rs` awaits the expiry task handle before calling + // `remove_connection`. Without quiescence, a REQ handler that acquired a + // permit before the cancel could register subscriptions AFTER `remove_connection` + // already ran — leaving orphan entries. With `quiesce()` in the cancellation + // arm, the task blocks until the permit is released, guaranteeing registration + // completes before teardown. + // + // Schedule: + // 1. Create a gate with a future deadline (so the sleep arm won't fire + // during the test) and acquire a permit. + // 2. Spawn the expiry task. It blocks in the select, waiting for either + // the sleep (far future) or cancellation. + // 3. Cancel the token — the cancellation arm fires, calls quiesce(). + // quiesce() blocks on the write guard because the permit (read guard) + // is still held. + // 4. Yield several times — assert the task has NOT completed. + // 5. Drop the permit — quiesce() acquires the write guard and finishes. + // The task completes. + // 6. Assert the task completed and the terminal channel is EMPTY (no second + // denial frame from the cancellation arm). + // + // Mutation evidence: + // A) Remove `gate.quiesce().await` from the cancellation arm → + // the task completes before the permit is dropped → + // "task must not have finished" assertion panics. + // B) Replace `quiesce()` with a bare `return` in the cancellation arm → + // same as (A). + // C) Remove the `gate.quiesce().await` and insert a `gate.expire()` call → + // a second terminal frame is enqueued → terminal channel is non-empty → + // "terminal channel must be empty" assertion panics. + + // ── W_f4_add_peer / W_f4_commit: delayed-expiry-task wire witnesses ───────── + // + // Both witnesses prove that the audio rejection paths (add-peer and commit) + // call `control.expiry_deny_terminal` BEFORE self-cancelling even when a + // spawned expiry task is running but hasn't yet fired its timer. + // + // Production scenario: + // `acquire_effect()` returns `SessionExpired` via the wall-clock path + // (Utc::now() >= deadline) before the expiry task's sleep fires. Without + // the fix, both rejection paths called raw `lifecycle_cancel()` first; the + // expiry task's select takes the cancellation arm and calls only quiesce(), + // NOT expiry_deny_terminal. Result: terminal channel empty → no restricted + // JSON, no 1008 policy close. + // + // With the fix: the rejection path calls `expiry_deny_terminal` first (wins + // the reason slot and enqueues the frame), then calls `lifecycle_cancel()`. + // The expiry task's cancel arm runs quiesce() — no second frame. + // + // Binding: both witnesses use a REAL gate with a past deadline (so + // acquire_effect actually returns SessionExpired) and a REAL spawned expiry + // task (far-future deadline, waiting in its sleep arm). These tests call + // `expiry_deny_terminal` DIRECTLY in the test body — they prove the primitive's + // ordering invariant (enqueue-before-cancel), not the handler's invocation of + // it. Handler-bound wire delivery is proven in + // `postgres_tests::f4_add_peer_expired_delivers_denial_before_close` and + // `postgres_tests::f4_commit_expired_delivers_denial_before_close`. + // + // Mutation evidence (for both witnesses): + // A) Remove the `expiry_deny_terminal` call from the rejection sequence → + // channel stays empty → "denial frame must be in terminal channel" panics. + // B) Swap order (lifecycle_cancel BEFORE expiry_deny_terminal) → if + // lifecycle_cancel wins first and the task takes the cancel arm (quiesce + // only), the frame is never enqueued → same panic. + // C) Produce a second expiry_deny_terminal call → second try_send returns + // Err(Full) (capacity 1), only one frame present — no double-frame. + + #[tokio::test] + async fn w_f4_add_peer_rejection_wire_delivers_denial_before_cancel() { + // Primitive-level ordering proof for the add-peer rejection sequence. + // + // What this proves: when `expiry_deny_terminal` runs BEFORE `lifecycle_cancel`, + // the terminal channel is populated before any cancel wakes a consumer. + // + // Production path (audio/handler.rs SessionExpired arm, line ~959): + // 1. acquire_effect() → Err(SessionExpired) + // 2. control.expiry_deny_terminal(&terminal_ctrl_tx, Audio) ← this call + // 3. control.lifecycle_cancel() + // + // This test calls `expiry_deny_terminal` directly (same call as the handler). + // Mutation within this test: + // Reorder steps 2 and 3 (call lifecycle_cancel before expiry_deny_terminal) + // → consumer wakes on empty channel → try_recv returns Err → assertion panics. + // + // Handler-bound wire delivery is proven in + // `postgres_tests::f4_add_peer_expired_delivers_denial_before_close`. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel::(1); + let cancel = CancellationToken::new(); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + // Gate with a past deadline → acquire_effect returns SessionExpired immediately. + let past_deadline = Utc::now() - chrono::Duration::seconds(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(past_deadline, cancel.clone()); + + // Spawn expiry task with a FAR-FUTURE deadline — it is waiting in its + // sleep arm and will never fire the timer arm during this test. + // When lifecycle_cancel() fires, the task takes the cancel arm (quiesce) + // NOT the timer arm (expiry_deny_terminal) — so no frame from the task. + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate_for_task = + crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + let expiry_task = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate_for_task), + terminal_tx.clone(), + NipFiWsRoute::Audio, + control.clone(), + ); + + // Verify acquire_effect returns SessionExpired (wall-clock path, past deadline). + let result = gate.acquire_effect().await; + assert!( + matches!(result, Err(crate::nip_fi_gate::SessionExpired)), + "W_f4_add_peer: gate with past deadline must return SessionExpired" + ); + + // Production rejection sequence — EXACTLY what audio/handler.rs does: + control.expiry_deny_terminal(&terminal_tx, NipFiWsRoute::Audio); + + // Frame must be in the channel BEFORE lifecycle_cancel fires. + let frame = terminal_rx.try_recv().expect( + "W_f4_add_peer: denial frame must be in terminal channel after expiry_deny_terminal \ + and before lifecycle_cancel (proves rejection path enqueues before self-cancel). \ + Mutation: reorder steps 2 and 3 → cancel fires first → Err(Empty) → RED.", + ); + let expected = authorization_denied_frame(NipFiWsRoute::Audio); + assert_eq!( + frame, expected, + "W_f4_add_peer: queued frame must be the canonical Audio authorization-denied frame" + ); + + // lifecycle_cancel fires — task takes the cancel arm (quiesce only, no frame). + control.lifecycle_cancel(); + assert!(cancel.is_cancelled(), "W_f4_add_peer: cancel must be set"); + + // Await the expiry task — must complete (quiesce with no held permits). + tokio::time::timeout(std::time::Duration::from_secs(2), expiry_task) + .await + .expect("W_f4_add_peer: expiry task must complete within 2s after cancel") + .expect("W_f4_add_peer: expiry task must not panic"); + + // Task's cancel arm (quiesce) must NOT have enqueued a second frame. + assert!( + terminal_rx.try_recv().is_err(), + "W_f4_add_peer: terminal channel must have exactly one frame \ + (expiry task cancel arm must not produce a duplicate via quiesce)" + ); + + // Reason slot must be AuthorizationDenied (not overwritten by task). + let reason = *control.disconnect_reason().borrow(); + assert_eq!( + reason, + Some(crate::state::CommunityDisconnectReason::AuthorizationDenied), + "W_f4_add_peer: reason slot must be AuthorizationDenied after expiry_deny_terminal" + ); + } + + #[tokio::test] + async fn w_f4_commit_rejection_wire_delivers_denial_before_cancel() { + // Primitive-level ordering proof for the commit rejection sequence. + // + // What this proves: when `expiry_deny_terminal` runs BEFORE `lifecycle_cancel`, + // the terminal channel is populated before any cancel wakes a consumer. + // + // Production path (audio/handler.rs JoinCommitError::Expired arm, line ~1306): + // 1. commit_participant_join() → Err(JoinCommitError::Expired) + // 2. control.expiry_deny_terminal(&terminal_ctrl_tx, Audio) ← this call + // 3. control.lifecycle_cancel() + // + // This test calls `expiry_deny_terminal` directly (same call as the handler). + // Mutation within this test: + // Reorder steps 2 and 3 → cancel fires first → try_recv returns Err → RED. + // + // Handler-bound wire delivery is proven in + // `postgres_tests::f4_commit_expired_delivers_denial_before_close`. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel::(1); + let cancel = CancellationToken::new(); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let past_deadline = Utc::now() - chrono::Duration::seconds(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(past_deadline, cancel.clone()); + + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate_for_task = + crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + let expiry_task = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate_for_task), + terminal_tx.clone(), + NipFiWsRoute::Audio, + control.clone(), + ); + + // Verify acquire_effect returns SessionExpired. + let result = gate.acquire_effect().await; + assert!( + matches!(result, Err(crate::nip_fi_gate::SessionExpired)), + "W_f4_commit: gate with past deadline must return SessionExpired" + ); + + // Production commit rejection path: + control.expiry_deny_terminal(&terminal_tx, NipFiWsRoute::Audio); + + let frame = terminal_rx.try_recv().expect( + "W_f4_commit: denial frame must be in terminal channel after expiry_deny_terminal \ + and before lifecycle_cancel (proves commit rejection enqueues before self-cancel). \ + Mutation: reorder steps 2 and 3 → cancel fires first → Err(Empty) → RED.", + ); + let expected = authorization_denied_frame(NipFiWsRoute::Audio); + assert_eq!( + frame, expected, + "W_f4_commit: queued frame must be the canonical Audio authorization-denied frame" + ); + + control.lifecycle_cancel(); + assert!(cancel.is_cancelled(), "W_f4_commit: cancel must be set"); + + tokio::time::timeout(std::time::Duration::from_secs(2), expiry_task) + .await + .expect("W_f4_commit: expiry task must complete within 2s after cancel") + .expect("W_f4_commit: expiry task must not panic"); + + assert!( + terminal_rx.try_recv().is_err(), + "W_f4_commit: terminal channel must have exactly one frame" + ); + + let reason = *control.disconnect_reason().borrow(); + assert_eq!( + reason, + Some(crate::state::CommunityDisconnectReason::AuthorizationDenied), + "W_f4_commit: reason slot must be AuthorizationDenied after expiry_deny_terminal" + ); + } + + #[tokio::test] + async fn w_f5_quiescence_cancel_arm_blocks_until_permits_released() { + let cancel = CancellationToken::new(); + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + + // Acquire a permit BEFORE spawning the task — simulates a REQ handler + // that obtained its permit before the admin disconnect fired. + let permit = gate.acquire_effect().await.expect("permit before cancel"); + + let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + + let task_handle = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate), + terminal_tx, + NipFiWsRoute::Root, + control, + ); + + // Simulate admin disconnect: cancel the connection token. + cancel.cancel(); + + // Task is now in its cancellation arm, blocked in quiesce() awaiting + // the write guard (which permit holds as a read guard). + // + // task_handle.is_finished() is cheap — just check the JoinHandle. + // Yield several times to let the async runtime run the task. + for _ in 0..10 { + tokio::task::yield_now().await; + } + + assert!( + !task_handle.is_finished(), + "W_f5: expiry task must not finish while a permit is still held \ + (quiesce() must block on the write guard)" + ); + + // Drop the permit — quiesce() can now acquire the write guard. + drop(permit); + + // Task should complete shortly after the permit is released. + tokio::time::timeout(std::time::Duration::from_secs(2), task_handle) + .await + .expect("W_f5: expiry task must complete within 2s after permit is released") + .expect("W_f5: expiry task must not panic"); + + // The cancellation arm must NOT enqueue a second terminal frame — + // the denial was already enqueued by the caller that cancelled the token. + assert!( + terminal_rx.try_recv().is_err(), + "W_f5: cancellation arm must not enqueue a terminal frame \ + (deny was already queued by the admin-cancel path; quiesce only waits, \ + never enqueues)" + ); + } + + // ── W_f5_registry: production-bound admin-disconnect path ──────────────── + // + // Proves the full F5 invariant using the real admin-disconnect path: + // `CommunityConnectionRegistry::disconnect_nip_fi(&pubkey)` (the actual call + // made by `AppState::disconnect_nip_fi` during admin-driven session removal). + // + // This witness goes through the production code path — not raw `cancel.cancel()`. + // `disconnect_nip_fi` on the registry: + // 1. Iterates connections, finds the one with the matching proven pubkey. + // 2. Calls `CommunityConnectionControl::disconnect_nip_fi` → acquires the + // transition lock, wins the reason slot, enqueues the denial frame on + // the terminal channel, releases the lock, then calls `cancel.cancel()`. + // 3. The expiry task's cancellation arm fires, calls `gate.quiesce()`, blocks + // until the held permit is released (proving no orphan-effects escape). + // + // Proved by this witness: + // - Exactly one denial frame is enqueued (by the admin path, not by quiesce). + // - The task's cancellation arm does NOT produce a second terminal frame. + // - The task waits for an outstanding effect permit before completing, + // verified via an explicit bounded rendezvous (quiesce_cancel_arm_hook) + // instead of a timing window. + // + // Mutation evidence: + // A) Remove `set_terminal_frame_sender` call → terminal_frame_tx slot is None → + // disconnect_nip_fi enqueues nothing → terminal_rx is empty → first + // `try_recv` assertion panics. + // B) Replace `gate.quiesce().await` with `gate.expire(|| {...}).await` in the + // cancel arm → `expire()` enqueues a second terminal frame → `try_recv` + // after task completion sees a frame → "must be empty" assertion panics. + // C) Remove `gate.quiesce().await` from the cancel arm → task exits the cancel + // arm without waiting → `quiesce_cancel_arm_hook::fire()` is still called + // (it is before the quiesce call), so `arrived_rx` does NOT time out; but + // the task completes while the permit is still held → the `is_finished` + // assertion panics. + #[tokio::test] + async fn w_f5_registry_admin_disconnect_enqueues_frame_quiesces_no_dup() { + use crate::state::{CommunityConnectionControl, CommunityConnectionRegistry}; + use buzz_core::tenant::CommunityId; + use uuid::Uuid; + + let community = CommunityId::from_uuid(Uuid::new_v4()); + let target_pubkey = vec![0xf5u8; 32]; + + let cancel = CancellationToken::new(); + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + + // Acquire a permit BEFORE the admin disconnect fires — simulates a REQ + // handler that holds an effect permit when the admin cancel arrives. + let permit = gate + .acquire_effect() + .await + .expect("W_f5_registry: permit must be available before cancel"); + + let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel.clone()); + + // Register the proven pubkey so registry.disconnect_nip_fi can find this + // control by pubkey (same as audio_post_auth_register does in production). + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + + // Register the terminal sender so disconnect_nip_fi can enqueue the denial + // frame on the correct channel (same as set_terminal_frame_sender does in + // handle_active_audio_connection before the first check_cancel!). + control.set_terminal_frame_sender(terminal_tx.clone()); + + // Register the control in the registry. + let registry = Arc::new(CommunityConnectionRegistry::new()); + let _guard = registry.register(Uuid::new_v4(), community, control.clone()); + + // Arm the quiesce-entry hook BEFORE spawning the task so the signal is + // ready when the cancel arm fires. + let quiesce_arrived_rx = quiesce_cancel_arm_hook::arm(control.hook_key); + + let task_handle = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate), + terminal_tx, + NipFiWsRoute::Root, + control.clone(), + ); + + // ── Admin disconnect: call the real production path. ────────────────── + // `CommunityConnectionRegistry::disconnect_nip_fi` finds the control by + // pubkey, calls `CommunityConnectionControl::disconnect_nip_fi` which + // enqueues the denial frame on `terminal_frame_tx`, publishes + // AuthorizationDenied, then calls `cancel.cancel()`. + let closed = registry.disconnect_nip_fi("test-issuer", &target_pubkey); + assert_eq!( + closed, 1, + "W_f5_registry: registry scan must find exactly 1 connection \ + (proves set_proven_pubkey ran before disconnect_nip_fi)" + ); + assert!( + cancel.is_cancelled(), + "W_f5_registry: cancel must be set after disconnect_nip_fi" + ); + + // The denial frame must be present immediately (enqueued by the admin path + // before cancel.cancel() fires — the transition lock guarantees this). + let frame = terminal_rx.try_recv().expect( + "W_f5_registry: denial frame must be in terminal channel immediately after \ + registry.disconnect_nip_fi (admin path enqueues before cancel). \ + Mutation A: remove set_terminal_frame_sender → frame absent → this panics.", + ); + let expected = authorization_denied_frame(NipFiWsRoute::Audio); + assert_eq!( + frame, expected, + "W_f5_registry: queued frame must be the canonical Audio authorization-denied frame \ + (CommunityConnectionControl::disconnect_nip_fi hard-codes NipFiWsRoute::Audio)" + ); + + // ── Explicit bounded rendezvous: task has entered the cancel arm. ───── + // Await the quiesce-entry hook signal — this proves the task reached the + // cancel arm and is about to call quiesce(). The permit is still held, + // so quiesce() will block; the task must not be finished yet. + // + // Mutation C: remove `gate.quiesce().await` from the cancel arm → task + // returns immediately after the hook fires → task IS finished before + // `is_finished` check → assertion panics. + tokio::time::timeout( + std::time::Duration::from_secs(2), + quiesce_arrived_rx, + ) + .await + .expect( + "W_f5_registry: quiesce-entry hook must fire within 2s of cancel \ + (proves task entered cancel arm and reached quiesce() call site). \ + Mutation C: remove gate.quiesce() → hook fires but task finishes → is_finished panics.", + ) + .expect("W_f5_registry: quiesce-entry hook sender must not be dropped"); + + assert!( + !task_handle.is_finished(), + "W_f5_registry: expiry task must not finish while a permit is still held \ + (quiesce() must block on the write guard). \ + Mutation C: remove quiesce() from cancel arm → task finishes early → this panics." + ); + // Cleanup: hook already consumed (oneshot); nothing to disarm. + + // Release the permit — quiesce() can now acquire the write guard. + drop(permit); + + tokio::time::timeout(std::time::Duration::from_secs(2), task_handle) + .await + .expect("W_f5_registry: expiry task must complete within 2s after permit released") + .expect("W_f5_registry: expiry task must not panic"); + + // The cancellation arm must NOT produce a second terminal frame — + // quiesce() only waits, never enqueues. + // Note: replacing quiesce() with expire() MAY or MAY NOT produce a second + // frame depending on whether the admin path has already won the reason slot + // (expire() is winner-only: if the reason is already set by the admin path + // it skips enqueue). The assertion here proves that quiesce() on its own + // does not enqueue — it makes no claim about expire() behaviour. + assert!( + terminal_rx.try_recv().is_err(), + "W_f5_registry: terminal channel must be empty after task completion \ + (quiesce does not enqueue — proved by admin path having already won \ + the reason slot before quiesce ran, and quiesce() containing no \ + enqueue call of its own)" + ); + } + + // ── W_f5_cleanup: sub_registry has zero orphan entries after teardown ───── + // + // Proves the complete F5 invariant through the subscription-registry cleanup + // seam: quiescence ensures that a REQ handler which acquired a permit + // BEFORE the admin cancel fires can still register its subscription in + // `sub_registry` WHILE the expiry task is blocked in quiesce(). Only after + // the permit is dropped (handler done) does quiesce() finish and the task + // return — giving production's `remove_connection` (which runs after + // `task.await` in connection.rs) the full subscription set to clean up. + // + // Without quiesce(): + // cancel fires → task exits immediately → remove_connection runs with zero + // entries → REQ handler then registers its subscription → orphan entry persists + // forever (no cleanup runs after teardown). + // + // With quiesce(): + // cancel fires → task blocks on write guard → REQ handler finishes and + // registers its subscription → drops permit → quiesce completes → task exits + // → remove_connection runs and finds the entry → zero orphans. + // + // This test models the full sequence: permit held before cancel, registration + // happens while quiesce is blocked, cleanup is complete and zero orphans remain. + // + // Mutation evidence: + // A) Remove `gate.quiesce().await` from the cancel arm → task exits before + // `sub_registry.register_scoped` runs → `remove_connection` finds nothing + // → orphan: the subscription registered AFTER teardown is never removed + // → the "removed count" assertion (zero orphans in registry after cleanup) + // would still pass, but the registration-before-cleanup seam is broken; + // the test covers this via hook ordering: quiesce hook fires, registration + // runs, permit drops, task finishes — this sequence is impossible without + // quiesce blocking. + // B) Move the registration to AFTER `drop(permit)` → registration happens + // after quiesce, not while it is blocked → not the in-flight-REQ scenario + // → the test models the wrong schedule; here registration is inside the + // permit window (lines ordered: arm hook → permit held → disconnect → + // await hook → register → drop permit). + #[tokio::test] + async fn w_f5_cleanup_sub_registry_zero_orphans_after_disconnect() { + use crate::state::{CommunityConnectionControl, CommunityConnectionRegistry}; + use crate::subscription::SubscriptionRegistry; + use buzz_core::tenant::CommunityId; + use nostr::Filter; + use uuid::Uuid; + + let community = CommunityId::from_uuid(Uuid::new_v4()); + let target_pubkey = vec![0xf5u8; 32]; + let conn_id = Uuid::new_v4(); + + let cancel = CancellationToken::new(); + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + + // ── Sub-registry (shared with the REQ handler simulation). ─────────── + let sub_registry = Arc::new(SubscriptionRegistry::new()); + + // ── Effect permit: acquired BEFORE admin disconnect fires. ───────── + // This represents a REQ handler that is mid-flight when the admin cancel + // arrives — it holds a permit and will register a subscription before + // dropping it, exactly the scenario quiesce() must protect. + let permit = gate + .acquire_effect() + .await + .expect("W_f5_cleanup: permit must be available before cancel"); + + let (terminal_tx, _terminal_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + control.set_terminal_frame_sender(terminal_tx.clone()); + + let registry = Arc::new(CommunityConnectionRegistry::new()); + let _guard = registry.register(Uuid::new_v4(), community, control.clone()); + + // Arm the quiesce-entry hook before spawning. + let quiesce_arrived_rx = quiesce_cancel_arm_hook::arm(control.hook_key); + + let task_handle = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate), + terminal_tx, + NipFiWsRoute::Root, + control.clone(), + ); + + // ── Admin disconnect via the real production path. ──────────────────── + let closed = registry.disconnect_nip_fi("test-issuer", &target_pubkey); + assert_eq!( + closed, 1, + "W_f5_cleanup: registry must find exactly 1 connection" + ); + + // ── Wait for the task to enter the cancel arm (quiesce blocked). ───── + tokio::time::timeout(std::time::Duration::from_secs(2), quiesce_arrived_rx) + .await + .expect("W_f5_cleanup: quiesce-entry hook must fire within 2s") + .expect("W_f5_cleanup: quiesce-entry hook sender must not be dropped"); + + // Task is blocked in quiesce() — permit is still held. + assert!( + !task_handle.is_finished(), + "W_f5_cleanup: expiry task must be blocked in quiesce() while permit is held" + ); + + // ── Simulate in-flight REQ registration while quiesce is blocked. ───── + // This is the critical sequence: the permit holder finishes its bounded + // work (subscription registration) WHILE the expiry task is waiting. + // Without quiesce(), the task would have already exited and remove_connection + // would have run — so this registration would become an orphan. + let sub_id = "test-sub-f5".to_string(); + let filters = vec![Filter::new()]; + sub_registry.register_scoped(community, conn_id, sub_id, filters, None); + + // Verify the subscription is visible in the registry while quiesce blocks. + assert_eq!( + sub_registry.total_subscriptions(), + 1, + "W_f5_cleanup: subscription must be registered while quiesce is blocked" + ); + + // ── Release the permit — quiesce() unblocks and the task completes. ── + drop(permit); + + tokio::time::timeout(std::time::Duration::from_secs(2), task_handle) + .await + .expect("W_f5_cleanup: expiry task must complete within 2s after permit released") + .expect("W_f5_cleanup: expiry task must not panic"); + + // ── Production teardown simulation: remove_connection runs AFTER task. ─ + // In connection.rs: `let _ = nip_fi_expiry_task.await` (line ~628) then + // `for removed in state.sub_registry.remove_connection(conn.conn_id)`. + // Because quiesce guaranteed the REQ handler finished before the task + // returned, remove_connection finds the registered subscription. + let removed = sub_registry.remove_connection(conn_id); + assert_eq!( + removed.len(), + 1, + "W_f5_cleanup: remove_connection must find exactly 1 subscription — \ + the one registered while quiesce was blocked. Zero means the \ + registration happened AFTER teardown (orphan scenario — quiesce broken)." + ); + + // After cleanup: no subscriptions remain. + assert_eq!( + sub_registry.total_subscriptions(), + 0, + "W_f5_cleanup: zero subscriptions must remain after remove_connection \ + (complete cleanup, no orphan entries)" + ); + } + + // ── W_f5_teardown: production teardown sequence (connection.rs:627-631) ── + // + // Models the exact teardown sequence from `connection.rs:627-631`: + // 1. `nip_fi_expiry_task.await` — task must complete only after all + // pre-cancel permit-holders finish (quiescence). + // 2. `sub_registry.remove_connection(conn_id)` — zero orphan subscriptions. + // + // The previous W_f5_cleanup test establishes the invariant with a REQ + // handler modelled by direct registry calls. This test drives the same + // sequence through the named production functions to make the connection + // explicit: admin `registry.disconnect_nip_fi` → expiry task quiesces → + // task completes → teardown removes subscriptions. + // + // ── Scope and known limits ───────────────────────────────────────────── + // + // This test exercises the quiescence invariant via the production components + // (`CommunityConnectionRegistry`, `spawn_nip_fi_expiry_task`, + // `registry.disconnect_nip_fi`) and the sub_registry primitives directly. + // It deliberately does NOT run `handle_req` or `pubsub.release_topic`. + // + // The full production teardown (including `release_topic` per removed scope + // and `conn_manager.deregister`) lives in `handle_active_connection`'s + // epilogue (connection.rs:631–648). That path is now covered by + // `f5_loopback_teardown_epilogue_removes_subscription_and_topic_refcount` + // in connection.rs, which drives a real `handle_active_connection` over + // a loopback WebSocket — no TLS or external relay required. This simulated + // test retains complementary low-level coverage of the quiescence barrier + // and sub_registry primitives; it is not the primary F5 witness. + // + // What this test verifies: + // - The quiescence barrier (`gate.quiesce()`) blocks the expiry task + // until the last pre-cancel permit-holder drops. + // - Admin disconnect via the real `CommunityConnectionRegistry` path + // enqueues exactly one terminal frame (Audio-format denial frame — + // `CommunityConnectionControl` always uses Audio JSON). + // - `sub_registry.remove_connection` finds and removes the subscription + // registered while quiescence was blocked — zero orphans. + // + // What is NOT verified here (requires live-socket integration test): + // - `pubsub.release_topic` called for each removed scope. + // - `conn_manager.deregister` called after task completion. + // + // Contract: + // - Exactly one terminal frame enqueued by the admin path (not by quiesce). + // - Task blocked until permit is released (quiescence proof). + // - Zero subscription orphans after teardown remove_connection. + // + // Mutation evidence: + // A) Remove `gate.quiesce().await` → task can exit before step 5's + // `register_scoped` in this test, but note: this test calls + // register_scoped unconditionally before remove_connection, so the + // ordering within the test is always correct. + // The mutation's real falsifiable claim is production-level: in the + // real handler a REQ arrives WHILE the expiry task is running; without + // quiescence the task exits before registration and the subscription + // orphans permanently. This test proves quiescence BLOCKS the task + // until the permit is released — an insertion after permit-drop would + // be lost, but step 5 occurs while the task is blocked, so the remove + // finds exactly 1. Remove quiesce → task_handle.is_finished() before + // step 5 → the is_finished assertion at step 4 panics. + // B) Replace admin `registry.disconnect_nip_fi` with raw `cancel.cancel()` + // → no terminal frame enqueued → first try_recv assertion panics. + #[tokio::test] + async fn w_f5_teardown_matches_connection_teardown_sequence() { + use crate::state::{CommunityConnectionControl, CommunityConnectionRegistry}; + use crate::subscription::SubscriptionRegistry; + use buzz_core::tenant::CommunityId; + use nostr::Filter; + use uuid::Uuid; + + let community = CommunityId::from_uuid(Uuid::new_v4()); + let conn_id = Uuid::new_v4(); + let target_pubkey = vec![0xf5u8; 32]; + + let cancel = CancellationToken::new(); + let far_future = Utc::now() + chrono::Duration::hours(1); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(far_future, cancel.clone()); + + // ── Step 1: acquire effect permit before admin disconnect (simulates an + // in-flight REQ handler). This is the window quiescence must protect. + let permit = gate + .acquire_effect() + .await + .expect("W_f5_teardown: permit must be available before cancel"); + + // ── Step 2: wire up the production components. + let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + control.set_terminal_frame_sender(terminal_tx.clone()); + + let registry = Arc::new(CommunityConnectionRegistry::new()); + let sub_registry = Arc::new(SubscriptionRegistry::new()); + let _guard = registry.register(Uuid::new_v4(), community, control.clone()); + + // Arm quiesce hook to observe task entering the quiescence barrier. + let quiesce_arrived_rx = quiesce_cancel_arm_hook::arm(control.hook_key); + + // Spawn the expiry task — mirrors `nip_fi_expiry_task` in connection.rs. + let task_handle = spawn_nip_fi_expiry_task( + far_future, + Arc::clone(&gate), + terminal_tx, + NipFiWsRoute::Root, + control.clone(), + ); + + // ── Step 3: admin disconnect via the real production path. + // This is `registry.disconnect_nip_fi("test-issuer", &pubkey)` — the call made by + // `AppState::disconnect_nip_fi` in the admin HTTP handler. + let closed = registry.disconnect_nip_fi("test-issuer", &target_pubkey); + assert_eq!( + closed, 1, + "W_f5_teardown: registry must find exactly 1 connection" + ); + + // Exactly one terminal frame enqueued by the admin path. + let frame = terminal_rx + .try_recv() + .expect("W_f5_teardown: admin disconnect must enqueue exactly one terminal frame"); + // `CommunityConnectionRegistry::disconnect_nip_fi` delegates to + // `CommunityConnectionControl::disconnect_nip_fi`, which always enqueues + // the Audio-route denial frame (audio JSON, not NOTICE). Root-path + // connections use `ConnectionManager::disconnect_nip_fi` instead. + let expected = crate::nip_fi_session::authorization_denied_frame(NipFiWsRoute::Audio); + assert_eq!( + frame, expected, + "W_f5_teardown: terminal frame must be the canonical Audio denial frame \ + (CommunityConnectionRegistry path always sends Audio format)" + ); + + // ── Step 4: task is now in its cancel arm, blocked in quiesce(). + // Wait for the task to enter quiescence (it holds the write guard). + tokio::time::timeout(std::time::Duration::from_secs(2), quiesce_arrived_rx) + .await + .expect("W_f5_teardown: quiesce-entry hook must fire within 2s") + .expect("W_f5_teardown: quiesce-entry hook sender must not be dropped"); + + assert!( + !task_handle.is_finished(), + "W_f5_teardown: task must be blocked in quiesce() while permit is held" + ); + + // ── Step 5: simulate in-flight REQ handler registering a subscription + // WHILE quiesce is blocking (the production race quiescence protects). + let sub_id = "test-sub-f5-teardown".to_string(); + let filters = vec![Filter::new()]; + sub_registry.register_scoped(community, conn_id, sub_id, filters, None); + + assert_eq!( + sub_registry.total_subscriptions(), + 1, + "W_f5_teardown: subscription must be registered while quiesce is blocked" + ); + + // ── Step 6: drop permit → quiesce() acquires write guard → task completes. + drop(permit); + + // ── Step 7: await task — mirrors `task.await` in connection.rs:628. + tokio::time::timeout(std::time::Duration::from_secs(2), task_handle) + .await + .expect("W_f5_teardown: task must complete within 2s after permit released") + .expect("W_f5_teardown: task must not panic"); + + // ── Step 8: remove_connection — mirrors connection.rs:631. + let removed = sub_registry.remove_connection(conn_id); + assert_eq!( + removed.len(), + 1, + "W_f5_teardown: remove_connection must find exactly 1 subscription — \ + the one registered in step 5 while quiesce was blocked. \ + (Note: in this test register_scoped is unconditionally called before \ + remove_connection; the quiescence proof is the task.is_finished() \ + check above, not this assertion)" + ); + + // Zero orphans remain — complete cleanup. + assert_eq!( + sub_registry.total_subscriptions(), + 0, + "W_f5_teardown: zero subscriptions must remain after remove_connection" + ); + + // No second terminal frame — quiesce does not enqueue. + assert!( + terminal_rx.try_recv().is_err(), + "W_f5_teardown: terminal channel must be empty after task completion \ + (quiesce does not enqueue; admin path already enqueued exactly one frame)" + ); + } +} diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs new file mode 100644 index 00000000000..40f412c3bac --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -0,0 +1,419 @@ +//! Test-only barriers for NIP-FI B2 witness tests. +//! +//! Each function is a named production hook that is inert in production +//! (`#[cfg(test)]` guards ensure zero-cost at runtime) but acts as a +//! deterministic barrier in tests. A test arms the gate, dispatches work, +//! waits for the arrived notification, fires expiry, then releases the gate. +//! +//! Pattern (same as `publish_test_hooks` in `side_effects.rs`): +//! - `arm(community)` → `(arrived_rx, release_notify)` +//! - Production code calls `before_X(community).await` +//! - Test awaits `arrived_rx.await` → knows production reached the hook +//! - Test fires expiry +//! - Test calls `release_notify.notify_one()` → production proceeds +//! +//! Only one gate per community-slot is supported at a time (static Mutex). +//! Tests using different communities can run concurrently — each gets its own gate. +//! Tests using the same community must not run concurrently (they will interfere). +//! +//! # Per-witness mutation-red table +//! +//! Every witness listed below follows the same structure: +//! +//! | Witness | Hook location (production file:line) | One-line mutation | Failing assertion | +//! |---------|--------------------------------------|-------------------|-------------------| +//! | **W1** (auth barrier) | `handlers/auth.rs:319` — immediately before `acquire_effect()` in AUTH commit path | Delete `before_auth_commit(...)` call | `arrived_rx` times out → test panics | +//! | **W1** (auth barrier) | same | Remove `acquire_effect()` from auth.rs | `auth_state is NOT Authenticated` → assertion panics | +//! | **W1** (auth barrier) | same | Change gate to `off_mode` | same as above | +//! | **W2** (event barrier) | `handlers/event.rs:784` — immediately before `acquire_effect()` in event ingest path | Delete `before_event_ingest(...)` call | `arrived_rx` times out → test panics | +//! | **W2** (event barrier) | same | Remove `acquire_effect()` from event.rs | "session expired" OK(false) not sent → first `try_recv` panics | +//! | **W2** (event barrier) | same | Change gate to `off_mode` | same as above | +//! | **W3** (REQ barrier) | `handlers/req.rs:280` — immediately before `acquire_effect()` in REQ path | Delete `before_req_registration(...)` call | `arrived_rx` times out → test panics | +//! | **W3** (REQ barrier) | same | Remove `acquire_effect()` from req.rs | subscription IS inserted → `subs.is_empty()` panics | +//! | **W3** (REQ barrier) | same | Change gate to `off_mode` | same as above | +//! | **W4** (COUNT barrier) | `handlers/count.rs:112` — immediately before `acquire_effect()` in COUNT path | Delete `before_count_query(...)` call | `arrived_rx` times out → test panics | +//! | **W4** (COUNT barrier) | same | Remove `acquire_effect()` from count.rs | CLOSED message changes from "session expired" → assertion panics | +//! | **W4** (COUNT barrier) | same | Change gate to `off_mode` | no CLOSED sent → `try_recv` returns `Err` → assertion panics | +//! | **W5** (audio B1 expired-at-pairing) | `audio/handler.rs`, B1 deadline check after NIP-42 auth | Remove the already-expired deadline check | frame text changes to "not a relay member" → byte assertion panics | +//! | **W6** (audio B1 mid-admission) | `audio/handler.rs`, biased `cancel.cancelled()` in auth select | Remove `_ = cancel.cancelled() => return` | handler proceeds to auth exchange; close assertion fires on 3s timeout | +//! | **W7** (audio B3 expiry writer) | `nip_fi_session::spawn_nip_fi_expiry_task`, audio enqueue | Delete the audio denial enqueue | `frames[0]` is not the expected restricted JSON → assertion panics | +//! | **W8** (audio membership barrier) | `audio/handler.rs:1572` — entry of `check_membership_for_admission` | Delete `before_membership_check(...)` call | `arrived_rx` times out → test panics | +//! | **W8** (audio membership barrier) | same | Move hook to after `state.db.get_channel()` | DB error fires before hook on lazy pool → `arrived_rx` times out | +//! | **W9** (audio participant-commit barrier) | `audio/handler.rs:1796` — between uncommitted 48101 insert and `acquire_effect()` | Delete `before_participant_commit(...)` call | `arrived_rx` times out — test panics | +//! | **W9** (audio participant-commit barrier) | same | Remove `tx.rollback()` from `SessionExpired` branch | sqlx rolls back on drop regardless — mutation does NOT change test outcome (explicit rollback is belt-and-suspenders); covered by W9C instead | +//! | **W9** (audio participant-commit barrier) | same | Remove `acquire_effect()` entirely | commit proceeds despite cancel — row committed — row-count assertion panics | +//! | **W10** (concurrent committers, different pubkeys) | same as W9 | Delete `before_participant_commit(...)` call | `arrived_rx` times out — test panics | +//! | **W10** (concurrent committers, different pubkeys) | same | Remove `acquire_effect()` from `commit_participant_join` | second task commits too — two rows present — row-count assertion panics | +//! | **W10-reaffirm** (same pubkey twice) | same as W9 | Delete `before_participant_commit(...)` call | `arrived_rx` times out — test panics | +//! | **CW5** (AutoAddRequired joint-tx rollback) | `audio/handler.rs` — `before_participant_commit` fires after BOTH membership insert AND 48101 insert are in the uncommitted tx | Delete `before_participant_commit(...)` call | `arrived_rx` times out — test panics | +//! | **CW5** (AutoAddRequired joint-tx rollback) | same | Remove `acquire_effect()` from `commit_participant_join` | both rows committed — membership row-count assertion panics | +//! | **CW5** (AutoAddRequired joint-tx rollback) | same | Change `membership_admission` to `Existing` | auto-add path never entered; membership seam not covered — test fails at isolation | +//! | **CW5-variant** (concurrent external membership add) | `audio/handler.rs` — `before_membership_lock` fires inside the `AutoAddRequired` branch immediately before the channel membership lock | Delete `before_membership_lock(...)` call | `arrived_rx` times out — test panics | +//! | **CW5-variant** (concurrent external membership add) | same | Remove the `still_absent` re-read and always insert | external membership may be double-written (ON CONFLICT behaviour) — re-read path is the contract seam; removing it bypasses the contract | +//! | **CW5-variant** (concurrent external membership add) | same | Remove the `if still_absent { insert }` guard | same as above — auto-add fires unconditionally alongside the external row | +//! | **CW8** (post-add_peer cancel → cleanup) | `audio/handler.rs` — `after_add_peer` fires immediately after `room.add_peer` succeeds and before the explicit `cancel.is_cancelled()` branch that calls `guard.release_before_commit()` | Delete `after_add_peer(...)` call | `arrived_rx` times out — test panics | +//! | **CW8** (post-add_peer cancel → cleanup) | same | Delete `room.remove_peer(peer_id)` from cleanup block | room entry persists — `room_after.is_none()` assertion panics | +//! | **CW8** (post-add_peer cancel → cleanup) | same | Delete `cleanup_if_empty(...)` from cleanup block | room entry persists — `room_after.is_none()` assertion panics | +//! | **CW10** (commit-won/quiescence: expiry blocked at barrier) | `audio/handler.rs` — `after_participant_fanout` fires after `tx.commit()` + fan-out, before `_permit` drops | Delete `after_participant_fanout(...)` call | `arrived_rx` times out — test panics | +//! | **CW10** (commit-won/quiescence: expiry blocked at barrier) | same | Remove `acquire_effect()` from `commit_participant_join` | permit never held — expiry completes before hook fires — `expire_done` is true before check — "expiry must be blocked" assertion panics | +//! | **CW10-full** (full-handler lifecycle: committed join → exactly one 48102) | `audio/handler.rs` — full `handle_active_audio_connection` via WS; hook at `after_participant_fanout`, then disconnect triggers normal teardown | Remove `emit_participant_event(48102, ...)` from handler epilogue | 48102 count stays 0 — assertion panics | +//! | **CW10-full** (full-handler lifecycle) | same | Remove `room.remove_peer_and_check_ended` from teardown | room entry persists — `audio_rooms.get()` returns Some — room assertion panics | +//! | **CW6** (guard-level: unattached lease released on pre-commit exit) | `audio/handler.rs` — `HuddleAdmissionGuard::release_before_commit` with injected `CountingDir` double (no Redis/mesh required) | Remove `if let Some((lease, directory)) = self.lease.take()` block from `release_before_commit` | `directory.release()` never called — `release_calls` stays 0 — assertion panics | +//! | **CW6** (guard-level: unattached lease released on pre-commit exit) | same | Short-circuit `release_before_commit` to return immediately before the lease block | same as above — `release_calls` stays 0 — assertion panics | +//! | **CW7** (guard-level: clean close sent on remote stream pre-commit exit) | `audio/handler.rs` — `HuddleAdmissionGuard::release_before_commit` with injected `RecordingSend` stub MeshStream + `RemoteHuddleSession::for_test` | Remove `if let (Some(session), Some(ref mut stream)) = ...` block from `release_before_commit` | `send_frame` never called — `goodbye_sent` is false — assertion panics | +//! | **CW7** (guard-level: clean close sent on remote stream pre-commit exit) | same | Swap `UnregisterPeer` and `Goodbye` frame order in `send_clean_close` | frames recorded in wrong order — assertion on Goodbye position panics | +//! +//! # Teardown ordering (quiescence citations) +//! +//! The quiescence requirement from the contract (e5bc0382): the expiry task must complete +//! (i.e., acquire and release the write guard after cancellation) before subscription/peer +//! cleanup runs. This prevents post-`remove_connection` subscription leaks. +//! +//! **Root WS** (`connection.rs:449-453`): +//! ```text +//! if let Some(task) = nip_fi_expiry_task { let _ = task.await; } // line 449 +//! for removed in state.sub_registry.remove_connection(...) // line 453 — after expiry +//! ``` +//! +//! **Audio WS** (`audio/handler.rs:1128-1138`): +//! ```text +//! if let Some(expiry_task) = nip_fi_audio_expiry_task { let _ = expiry_task.await; } // line 1128 +//! room.remove_peer_and_check_ended(peer_id) // line 1138 — after expiry +//! ``` +//! +//! **Pre-existing cleanup helpers** (audio expiry path): +//! - `send_clean_close` (`audio/join.rs`) — sends WS close frame for remote session path +//! - `cleanup_if_empty` (`audio/rooms.rs`) — removes room when peer count drops to zero +//! - `room.remove_peer` (`audio/room.rs`) — removes peer from in-memory room roster + +use buzz_core::CommunityId; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; +use tokio::sync::{oneshot, Notify}; + +struct Gate { + arrived: oneshot::Sender<()>, + release: Arc, +} + +macro_rules! make_hook { + ($mod_name:ident, $fn_name:ident) => { + pub(crate) mod $mod_name { + use super::*; + + // Keyed by CommunityId so concurrent tests with different communities + // can arm independent gates without overwriting each other. + static GATE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + /// Arm a one-shot barrier for `community`. + /// + /// Returns `(arrived_rx, release)`. Await `arrived_rx` to know when + /// the production code has reached this hook; call `release.notify_one()` + /// to let it continue. + pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc) { + let (tx, rx) = oneshot::channel(); + let release = Arc::new(Notify::new()); + GATE.lock().unwrap().insert( + community, + Gate { + arrived: tx, + release: release.clone(), + }, + ); + (rx, release) + } + + pub(crate) async fn trigger(community: CommunityId) { + let gate = GATE.lock().unwrap().remove(&community); + if let Some(g) = gate { + let _ = g.arrived.send(()); + g.release.notified().await; + } + } + } + + pub(crate) async fn $fn_name(community: CommunityId) { + $mod_name::trigger(community).await; + } + }; +} + +make_hook!(auth_commit_hook, before_auth_commit); +make_hook!(event_ingest_hook, before_event_ingest); +make_hook!(req_registration_hook, before_req_registration); +make_hook!(count_query_hook, before_count_query); + +// ── Audio B1 hooks ───────────────────────────────────────────────────────── +// `before_membership_check`: fires between NIP-42 pairing and the membership +// DB read inside `check_membership_for_admission`. Arms expiry here → proves +// that a cancellation before membership check produces zero DB side effects. +// +// `before_membership_lock`: fires inside the AutoAddRequired branch of +// `commit_participant_join`, immediately before +// `acquire_channel_membership_lock_in_transaction`. Arms an external +// membership insert here → proves that a concurrent add is observed by the +// re-read and the auto-add insert is skipped, leaving membership preserved. +// +// `before_participant_commit`: fires between the 48101 insert and the +// `acquire_effect()` + `tx.commit()` inside `commit_participant_join`. Arms +// expiry here → proves that a cancellation before the permit acquisition +// rolls back the transaction and produces zero post-expiry 48101/membership +// writes. +// +// `after_participant_fanout`: fires inside `commit_participant_join` after the +// 48101 is committed AND fan-out is complete but BEFORE `_permit` drops. +// Used by CW10: arms expiry here → proves expiry is blocked at the write +// guard while the permit is held; releasing the hook drops the permit and +// unblocks expiry. +// +// `after_add_peer`: fires in `handle_active_audio_connection` immediately +// after a successful `room.add_peer` call and before the subsequent +// `check_cancel!` fence. Arms cancel here → proves the cleanup branch +// (`room.remove_peer` + `cleanup_if_empty`) runs before the handler returns. +make_hook!(audio_membership_check_hook, before_membership_check); +make_hook!(audio_membership_lock_hook, before_membership_lock); +make_hook!(audio_participant_commit_hook, before_participant_commit); +make_hook!(audio_participant_fanout_hook, after_participant_fanout); +make_hook!(audio_add_peer_hook, after_add_peer); + +// ── Deny-set admission hooks ─────────────────────────────────────────────── +// `before_deny_set_check`: fires in BOTH the root WS handler (handlers/auth.rs) +// and the audio handler (audio/handler.rs), immediately AFTER +// `set_authenticated_pubkey`/`audio_post_auth_register` (registration) and +// immediately BEFORE the `is_denied(iss, k, now)` call. +// +// The straddle witness arms this gate, then inserts a deny entry in the window +// between registration and check. The invariant covers BOTH sides of the race: +// (a) a concurrent disconnect sees the registered session and closes it (close +// scan side) — exercised by the real `disconnect_nip_fi` call in the test, +// which asserts exactly 1 session found at the hook window; OR +// (b) the deny check fires here and finds the entry (check side) — exercised by +// the is_cancelled + AuthorizationDenied NOTICE oracles. +// +// Mutation evidence (W_deny_straddle, W_audio_deny): +// A) Delete `before_deny_set_check(...)` from auth.rs / audio/handler.rs → +// handler never stalls → deny entry is inserted AFTER the check already +// ran and missed it → connection is admitted → `is_cancelled()` assertion +// panics. +// B) Remove the `is_denied` check entirely → same outcome as (A). +// C) Move `before_deny_set_check` to BEFORE `set_authenticated_pubkey` → +// hook fires before registration → close-scan side fails (disconnect finds +// 0 sessions) → `assert_eq!(scan_count, 1)` panics regardless of whether +// (b) still catches the deny. +make_hook!(deny_set_check_hook, before_deny_set_check); + +// `before_first_audio_check_cancel`: fires in `handle_active_audio_connection` +// immediately before the first `check_cancel!()` invocation (after +// `enforce_relay_membership` returns and before Step 3 membership check). +// By this point the NIP-FI expiry task has already been spawned (line ~426), +// so tests can hold the handler here while the expiry task fires naturally, +// then release to let `check_cancel!()` drain the terminal channel and emit +// the policy close. Used by W_FIX1. +// +// Mutation evidence (W_FIX1): +// A) Delete the `if let Some(reason) = nip_fi_close_reason` block from the +// plain `check_cancel!()` arm → client receives only restricted JSON, no +// close → W_FIX1 close assertion panics. +// B) Move this hook to before `spawn_nip_fi_expiry_task` → expiry task fires +// AFTER hook releases → cancel not set when check_cancel!() runs → +// handler proceeds to membership check instead of returning → +// W_FIX1 frame-0 assertion times out → panics. +make_hook!( + audio_before_first_check_cancel_hook, + before_first_audio_check_cancel +); + +// `after_deny_set_check_passed`: fires in the audio handler immediately after the +// deny-set check block completes WITHOUT denying (i.e., the key passed). Used by +// `w_audio_deny_absent` to prove the absent key reached the post-check/membership +// gate without being denied or cancelled. +// +// Mutation evidence (W_audio_deny_absent): +// A) Invert `is_denied` → the absent key is denied BEFORE this hook fires → +// handler returns early → hook never fires → `arrived_rx` times out → panics. +// B) Move the hook to before the deny check → fires unconditionally regardless +// of denial; but the cancel assertion (not yet set) would still pass the +// absent case until after release — use in combination with the active test. +make_hook!( + audio_after_deny_check_passed_hook, + after_deny_set_check_passed +); + +// ── Publication-attempt counter ──────────────────────────────────────────── +// `before_event_publish`: fires immediately before `state.pubsub.publish_event` +// in `dispatch_persistent_event_inner`. Used by W2: after handle_event returns +// under session-expired, assert this counter is 0 — proves `publish_event` was +// never called (real publication boundary, not a proxy). +// +// Mutation evidence (W2): +// Remove `acquire_effect()` from event.rs → ingest_event is called → +// dispatch_persistent_event_inner runs → before_event_publish fires → +// counter = 1 → `assert_eq!(publish_count, 0)` panics. +pub(crate) mod event_publish_counter { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTERS: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + + /// Register a counter for `community` and return it. The counter starts at 0 + /// and is incremented each time `before_event_publish` fires for this community. + pub(crate) fn register(community: CommunityId) -> Arc { + let counter = Arc::new(AtomicU32::new(0)); + COUNTERS.lock().unwrap().insert(community, counter.clone()); + counter + } + + /// Deregister the counter for `community` (call after the test assertion). + pub(crate) fn deregister(community: CommunityId) { + COUNTERS.lock().unwrap().remove(&community); + } + + pub(crate) fn increment(community: CommunityId) { + if let Some(counter) = COUNTERS.lock().unwrap().get(&community) { + counter.fetch_add(1, Ordering::Relaxed); + } + } +} + +pub(crate) fn before_event_publish(community: CommunityId) { + event_publish_counter::increment(community); +} + +// ── Observer-permit hook ─────────────────────────────────────────────────── +// `before_observer_publication`: fires immediately before `acquire_effect` in +// `handle_agent_observer_event`, after all validation and owner checks. +// Used by the F1 witness: after expiry is armed and the handler reaches this +// hook, fire cancel so `acquire_effect` returns `SessionExpired` and the +// observer frame is NOT published. +// +// Mutation evidence (F1 witness): +// A) Delete `#[cfg(test)] before_observer_publication(...)` call → +// `arrived_rx` times out → test panics. +// B) Remove `acquire_effect` from the observer handler → +// handler calls publish_event even when expired → OK(true) sent → +// assertion on the cancel-before-acquisition invariant panics. +// C) Move hook to after `mark_local_event` (past the permit) → +// hook fires after the irreversible side effect; expired gate +// can no longer prevent delivery → publication counter shows 1 → panics. +make_hook!(observer_publication_hook, before_observer_publication); + +// ── F4: add-peer gate-acquire hook ──────────────────────────────────────── +// `before_add_peer_gate_acquire`: fires in `handle_active_audio_connection` +// immediately before `audio_gate.acquire_effect()` in Step 5 (add-peer). +// After auth + membership pass, the handler reaches this hook just before the +// gate acquire that may return `SessionExpired`. +// +// F4 add-peer test arms this hook, awaits arrival (handler at the gate), +// then expires the gate manually (`gate.expire()` or via cancellation). +// Releases the hook → handler resumes → `acquire_effect()` returns +// `SessionExpired` → handler calls `expiry_deny_terminal` + R2 drain + +// sends denial frame + close. The WS client then observes: +// frame 0: restricted JSON (Audio authorization-denied payload) +// frame 1: 1008 POLICY close +// +// Removing `expiry_deny_terminal` from the `SessionExpired` arm makes frame 0 +// either absent or a close-only, which breaks the frame-0 equality assertion. +// Removing R2 drain makes frame 0 absent (denial queued but never drained). +// +// Mutation evidence: +// A) Remove `expiry_deny_terminal` from the add-peer SessionExpired arm → +// frame 0 is not the denial JSON → wire assertion panics. +// B) Remove R2 drain (`while let Ok(msg) = terminal_ctrl_rx.try_recv()`) → +// denial queued but never sent → frame 0 is missing → timeout panics. +// C) Delete `before_add_peer_gate_acquire(...)` from handler → +// arrived_rx times out → test panics (proves hook is at the right seam). +make_hook!( + audio_add_peer_gate_acquire_hook, + before_add_peer_gate_acquire +); + +// ── R2: room-ended error arm — admin fires before lifecycle_cancel ───────── +// `before_room_ended_lifecycle_cancel`: fires in the `AdmissionError::Ended` +// arm of `handle_active_audio_connection`, AFTER the room_ended error frame +// is sent to the client but BEFORE `lifecycle_cancel()` is called. +// +// R2 test arms this hook, awaits arrival, then calls +// `registry.disconnect_nip_fi` from the test (admin path: wins reason, +// enqueues denial frame, fires cancel). Releases the hook → handler calls +// `lifecycle_cancel()` (loses reason, but cancel is set), awaits expiry, +// then the R2 drain delivers the pre-queued denial frame + close. The WS +// client observes the denial frame + POLICY close after the room_ended error. +// +// Mutation evidence: +// A) Remove R2 drain from the Ended arm → denial queued but never sent → +// client never sees the denial frame → timeout after room_ended → panics. +// B) Delete `before_room_ended_lifecycle_cancel(...)` from handler → +// `arrived_rx` times out → test panics (proves hook is at the right seam). +// C) Move R2 drain to BEFORE guard.release_before_commit() → out-of-order +// delivery — but functionally equivalent; test checks frame ORDER, not +// guard timing. +make_hook!( + audio_room_ended_lifecycle_cancel_hook, + before_room_ended_lifecycle_cancel +); + +// ── R2: drain_terminal!() — producer-paused race witness ────────────────── +// `before_not_a_member_drain_terminal`: fires in `handle_active_audio_connection` +// in the `check_membership_for_admission` error arm (not-a-member), AFTER the +// diagnostic "not a member" frame is sent but BEFORE `drain_terminal!()` is called. +// +// This is the hook that makes the R2 race witness specifically exercise the +// `drain_terminal!()` macro (which calls `lifecycle_cancel()` internally). +// It differs from `before_room_ended_lifecycle_cancel`, which is on a path that +// calls explicit `lifecycle_cancel()` before `finalize_drain!()`. +// +// R2 race test sequence: +// 1. Arm this hook + arm `cancel_race_test_hook` on the control. +// 2. Connect a non-member client: handler reaches this hook, pauses. +// 3. Test fires `disconnect_nip_fi` in a thread. Admin wins reason, fires +// `cancel_race_test_hook` (lock held, before try_send) → admin pauses. +// 4. Test awaits admin's ready signal. +// 5. Test releases this hook → handler proceeds to `drain_terminal!()` → +// `lifecycle_cancel()` tries to acquire the transition lock → BLOCKS +// (admin holds it). +// 6. Test releases admin's hook → admin completes `try_send`, drops lock. +// 7. `lifecycle_cancel()` unblocks → drains → denial frame delivered → close. +// +// Falsification: remove `lifecycle_cancel()` from the `drain_terminal!()` macro → +// handler drains immediately (lock not acquired, admin may not have enqueued yet) +// → `try_recv()` returns Err(Empty) → denial frame absent → only close frame +// seen by client → frame-1 assertion panics. +// +// Mutation evidence: +// A) Delete `before_not_a_member_drain_terminal(...)` from handler → +// `arrived_rx` times out → test panics (proves hook is at the right seam). +// B) Remove `lifecycle_cancel()` from `drain_terminal!()` → +// handler does not block on admin's lock → drain races admin's try_send +// → denial frame absent after close → frame-1 timeout → panics. +make_hook!( + audio_not_a_member_drain_terminal_hook, + before_not_a_member_drain_terminal +); +// ── F5: REQ permit-acquired hook ────────────────────────────────────────── +// `after_req_permit_acquired`: fires in `handlers/req.rs` immediately AFTER +// `acquire_effect()` returns `Ok(permit)` (permit is now held) but BEFORE +// `sub_registry.register_scoped` / `register_channels_scoped`. +// +// This hook establishes a permit-holding REQ handler that has not yet +// registered its subscription. The F5 loopback teardown witness uses this +// to drive a real `handle_active_connection` to this state, fire admin +// disconnect, then release — letting the real connection epilogue +// (`remove_connection` + `release_topic`) run and asserting zero topic +// refcounts remain. +// +// Mutation evidence (F5 loopback test): +// A) Remove `gate.quiesce().await` from the expiry task cancel arm → +// expiry task exits before REQ registers its subscription → +// connection epilogue runs `remove_connection` on an empty set → +// the subscription registered after task exit orphans permanently → +// but the test actually fires disconnect WHILE the permit is held, so +// quiescence is still the binding contract. +// B) Remove `acquire_effect()` from req.rs → handler inserts subscription +// without the permit → quiescence barrier not crossed → subscription +// is registered after task exits → `remove_connection` finds 0 entries → +// topic_refcount stays > 0 → assertion panics. +// C) Delete `after_req_permit_acquired(...)` from req.rs → +// `arrived_rx` times out → test panics (proves hook is at correct seam). +make_hook!(req_permit_acquired_hook, after_req_permit_acquired); diff --git a/crates/buzz-relay/src/nip_fi_upgrade.rs b/crates/buzz-relay/src/nip_fi_upgrade.rs new file mode 100644 index 00000000000..75717ddcb42 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -0,0 +1,500 @@ +//! NIP-FI assertion validation at WebSocket upgrade. +//! +//! This module owns the exact NIP-FI HTTP denial contract for upgrade denials +//! and the header-parsing that feeds assertion validation. +//! +//! Per [NIP-FI.md](../../../docs/nips/NIP-FI.md) §Client-attached transport: +//! - Exactly one `Nostr-Federated-Identity: Bearer ` field. +//! - Missing, repeated, comma-combined, empty, non-Bearer, and mixed-profile +//! fields all deny. [FI-TRACE-TRANSPORT-CLOSED] +//! - Per §Rejection table, pre-101 denials are HTTP responses; the exact wire +//! contract is fixed (status, body, headers). [FI-TRACE-DENIAL-ORACLE] + +use axum::body::Body; +use axum::http::{HeaderMap, Response, StatusCode}; +use buzz_auth::{ + DenialClass, FederatedAssertionVerifier, IssuerKeySource, NipFiMode, VerifiedAssertion, + CLIENT_ATTACHED_HEADER, +}; + +/// Outcome of NIP-FI assertion validation at upgrade time. +pub(crate) enum NipFiUpgradeOutcome { + /// Assertion validated successfully. Carry the result into the connection. + Admitted(VerifiedAssertion), + /// Enforcement is off — no assertion required. + NotRequired, + /// Enforcement active but assertion absent/rejected — return the HTTP + /// denial response. + Denied(Response), +} + +/// Validate the NIP-FI assertion on a WebSocket upgrade request. +/// +/// Returns: +/// - `NotRequired` when the relay is in `Off` mode. +/// - `Admitted(assertion)` when the token is present, valid, and passes. +/// - `Denied(response)` with the exact NIP-FI HTTP denial contract otherwise. +/// +/// The `DenyProtected` mode always returns `Denied(authorization_unavailable)` +/// (503), not `Denied(authorization_denied)` (403). This is intentional: +/// `DenyProtected` is operator-declared repair mode — the client's evidence may +/// be valid but authorization is temporarily unavailable — so "authorization +/// denied" would be false. "authorization unavailable, retry after repair" is +/// the accurate and correct signal. [FI-TRACE-DENIAL-ORACLE] +pub(crate) fn check_nip_fi_at_upgrade( + headers: &HeaderMap, + verifier: Option<&FederatedAssertionVerifier>, + mode: NipFiMode, +) -> NipFiUpgradeOutcome { + if matches!(mode, NipFiMode::Off) { + return NipFiUpgradeOutcome::NotRequired; + } + + if matches!(mode, NipFiMode::DenyProtected) { + return NipFiUpgradeOutcome::Denied(denial_response(DenialClass::AuthorizationUnavailable)); + } + + // Enforce mode: validate the assertion. + let token = match extract_bearer_token(headers) { + Ok(t) => t, + Err(class) => return NipFiUpgradeOutcome::Denied(denial_response(class)), + }; + + let verifier = match verifier { + Some(v) => v, + None => { + // Verifier not yet constructed (startup race); fail closed. + return NipFiUpgradeOutcome::Denied(denial_response( + DenialClass::AuthorizationUnavailable, + )); + } + }; + + match verifier.verify(token) { + Ok(assertion) => NipFiUpgradeOutcome::Admitted(assertion), + Err(err) => { + tracing::debug!(code = err.code(), "nip-fi assertion denied at upgrade"); + NipFiUpgradeOutcome::Denied(denial_response(err.denial_class())) + } + } +} + +/// Extract the single `Bearer ` value from the NIP-FI header. +/// +/// Rejects all forms the spec prohibits: +/// - absent → `MissingEvidence` +/// - repeated (multiple header values) → `EvidenceRejected` +/// - comma-combined (`,` in a single value) → `EvidenceRejected` +/// - empty after `Bearer ` stripping → `EvidenceRejected` +/// - non-`Bearer ` prefix → `EvidenceRejected` +/// - value containing whitespace after the scheme → `EvidenceRejected` +/// +/// [FI-TRACE-TRANSPORT-CLOSED] +fn extract_bearer_token(headers: &HeaderMap) -> Result<&str, DenialClass> { + let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter(); + let first = match values.next() { + Some(v) => v, + None => return Err(DenialClass::MissingEvidence), + }; + // Repeated header fields deny. + if values.next().is_some() { + return Err(DenialClass::EvidenceRejected); + } + let raw = first.to_str().map_err(|_| DenialClass::EvidenceRejected)?; + // Comma-combined values deny. + if raw.contains(',') { + return Err(DenialClass::EvidenceRejected); + } + // Must be `Bearer ` — exactly that prefix. + let token = raw + .strip_prefix("Bearer ") + .ok_or(DenialClass::EvidenceRejected)?; + // Empty value after stripping denies. + if token.is_empty() { + return Err(DenialClass::EvidenceRejected); + } + // Whitespace within the token denies (mixed-profile detection). + if token.contains(char::is_whitespace) { + return Err(DenialClass::EvidenceRejected); + } + Ok(token) +} + +/// Build the exact NIP-FI HTTP denial response for a WebSocket upgrade request. +/// +/// Per the NIP-FI rejection table: status + exact body + `Content-Type`. +/// `MissingEvidence` additionally carries `WWW-Authenticate: Nostr`. +/// No free text, request ID, or per-principal information. [FI-TRACE-DENIAL-ORACLE] +pub(crate) fn denial_response(class: DenialClass) -> Response { + let status = + StatusCode::from_u16(class.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + let mut builder = Response::builder() + .status(status) + .header("Content-Type", class.content_type()); + + if let Some(www_auth) = class.www_authenticate() { + builder = builder.header("WWW-Authenticate", www_auth); + } + + builder + .body(Body::from(class.http_body())) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers_with(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_str(value).unwrap(), + ); + h + } + + // ── transport parsing ───────────────────────────────────────────────────── + + #[test] + fn absent_header_gives_missing_evidence() { + let h = HeaderMap::new(); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::MissingEvidence)), + "absent NIP-FI header must be MissingEvidence" + ); + } + + #[test] + fn repeated_header_gives_evidence_rejected() { + let mut h = HeaderMap::new(); + h.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer aaa.bbb.ccc"), + ); + h.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer ddd.eee.fff"), + ); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "repeated NIP-FI header must be EvidenceRejected" + ); + } + + #[test] + fn comma_combined_gives_evidence_rejected() { + let h = headers_with("Bearer aaa.bbb.ccc, Bearer ddd.eee.fff"); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "comma-combined NIP-FI header must be EvidenceRejected" + ); + } + + #[test] + fn empty_value_gives_evidence_rejected() { + let h = headers_with(""); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "empty NIP-FI header must be EvidenceRejected" + ); + } + + #[test] + fn non_bearer_prefix_gives_evidence_rejected() { + let h = headers_with("Token aaa.bbb.ccc"); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "non-Bearer scheme must be EvidenceRejected" + ); + } + + #[test] + fn bearer_with_empty_token_gives_evidence_rejected() { + let h = headers_with("Bearer "); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "empty token after Bearer must be EvidenceRejected" + ); + } + + #[test] + fn whitespace_in_token_gives_evidence_rejected() { + let h = headers_with("Bearer aa bb.ccc.ddd"); + assert!( + matches!(extract_bearer_token(&h), Err(DenialClass::EvidenceRejected)), + "whitespace in token must be EvidenceRejected (mixed-profile)" + ); + } + + #[test] + fn valid_bearer_token_is_extracted() { + let h = headers_with("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"); + let token = extract_bearer_token(&h).expect("valid Bearer header must succeed"); + assert_eq!(token, "eyJhbGciOiJFUzI1NiJ9.e30.sig"); + } + + // ── denial response contract ────────────────────────────────────────────── + // + // NIP-FI requires the EXACT bytes; tests assert on exact body + headers. + // [FI-TRACE-DENIAL-ORACLE] + + fn body_bytes(resp: Response) -> Vec { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(async { + axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap() + .to_vec() + }) + } + + #[test] + fn missing_evidence_response_is_401_with_www_authenticate() { + let resp = denial_response(DenialClass::MissingEvidence); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()), + Some("Nostr"), + "MissingEvidence must carry WWW-Authenticate: Nostr" + ); + assert_eq!( + resp.headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8") + ); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + + #[test] + fn evidence_rejected_response_is_403_exact_body() { + let resp = denial_response(DenialClass::EvidenceRejected); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "EvidenceRejected must not carry WWW-Authenticate" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + } + + #[test] + fn authorization_denied_response_is_403_exact_body() { + let resp = denial_response(DenialClass::AuthorizationDenied); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body_bytes(resp), b"authorization denied\n"); + } + + #[test] + fn authorization_unavailable_response_is_503_exact_body() { + let resp = denial_response(DenialClass::AuthorizationUnavailable); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + + #[test] + fn private_state_denials_are_byte_identical() { + // The spec's FI-TRACE-DENIAL-ORACLE: all private-state denial causes + // (key mismatch, claimless assertion, expired lease) MUST map to the + // same denial class (`AuthorizationDenied`) and produce byte-identical + // wire frames on both ingresses. + // + // With `enforce_nip_fi_key_pairing` owning the full denial path, both + // conditions reach the exact same `authorization_denied_frame(route)` + // call. This test pins that call against the production frame builder + // and asserts that: + // 1. Root and audio denial frames carry the correct denial text. + // 2. `AuthorizationDenied` HTTP response is 403 exact bytes. + // 3. `EvidenceRejected` (public) is distinct from `AuthorizationDenied` + // (private-state) — the oracle property. + // + // Mutation evidence: + // A) Change `DenialClass::AuthorizationDenied` in `authorization_denied_frame` + // → `nostr_text()` differs → root/audio text assertions panic. + // B) Swap the root NOTICE with a raw string → JSON parse fails or + // content assertion panics. + // C) Map `EvidenceRejected` to the same body → distinctness assert panics. + use crate::nip_fi_session::{authorization_denied_frame, NipFiWsRoute}; + use axum::extract::ws::Message as WsMessage; + + let expected_text = buzz_auth::DenialClass::AuthorizationDenied.nostr_text(); + + // Root frame: NOTICE JSON, content == nostr_text(). + let root_frame = authorization_denied_frame(NipFiWsRoute::Root); + match root_frame { + WsMessage::Text(t) => { + let v: serde_json::Value = + serde_json::from_str(&t).expect("root denial frame is valid JSON"); + let content = v.get(1).and_then(|c| c.as_str()).unwrap_or(""); + assert_eq!( + content, expected_text, + "root denial frame content must equal AuthorizationDenied.nostr_text()" + ); + } + other => panic!("root denial frame must be WsMessage::Text; got {other:?}"), + } + + // Audio frame: JSON object with type/message fields. + let audio_frame = authorization_denied_frame(NipFiWsRoute::Audio); + match audio_frame { + WsMessage::Text(t) => { + let v: serde_json::Value = + serde_json::from_str(&t).expect("audio denial frame is valid JSON"); + assert_eq!( + v.get("type").and_then(|x| x.as_str()), + Some("restricted"), + "audio denial frame type must be 'restricted'" + ); + assert_eq!( + v.get("message").and_then(|x| x.as_str()), + Some(expected_text), + "audio denial frame message must equal AuthorizationDenied.nostr_text()" + ); + } + other => panic!("audio denial frame must be WsMessage::Text; got {other:?}"), + } + + // HTTP-level oracle: AuthorizationDenied → 403 exact bytes. + let resp_private = denial_response(DenialClass::AuthorizationDenied); + assert_eq!(resp_private.status(), StatusCode::FORBIDDEN); + assert_eq!( + body_bytes(resp_private), + b"authorization denied\n", + "private-state denial HTTP body must be 'authorization denied\\n' [FI-TRACE-DENIAL-ORACLE]" + ); + + // Distinctness: public-evidence denial (EvidenceRejected) produces + // different bytes from private-state denial (AuthorizationDenied). + let resp_evidence = denial_response(DenialClass::EvidenceRejected); + let resp_private2 = denial_response(DenialClass::AuthorizationDenied); + assert_ne!( + body_bytes(resp_evidence), + body_bytes(resp_private2), + "public-evidence denial must be distinct from private-state denial" + ); + } + + // ── Router-level gate: enforce mode, both WS ingresses ──────────────────── + // + // `check_nip_fi_at_upgrade` is the single pre-101 gate called by BOTH the + // root relay handler and the huddle audio handler (C1). Tests here drive it + // with the exact request shapes that must deny and admit, establishing the + // per-function mutation boundary. + // + // Note: these unit tests call `check_nip_fi_at_upgrade` directly and do NOT + // falsify that the gate is wired into the router. The built-router integration + // tests in `router.rs` (`nip_fi_enforce_*`) exercise the full WS upgrade + // path through the real router for both `/` and `/huddle/{id}/audio` — + // deleting either production gate call turns those tests red. + // + // Enforce + no verifier → 503 (dependency fail-closed; startup race) + #[test] + fn enforce_no_verifier_returns_503_exact_bytes() { + // A None verifier in enforce mode means startup race — must deny 503. + let headers = HeaderMap::new(); + // add a valid-looking header so we don't short-circuit on missing evidence + let mut h = headers; + h.insert( + CLIENT_ATTACHED_HEADER, + axum::http::HeaderValue::from_static("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), + ); + let outcome = check_nip_fi_at_upgrade( + &h, + None::<&buzz_auth::FederatedAssertionVerifier>, + buzz_auth::NipFiMode::Enforce, + ); + match outcome { + NipFiUpgradeOutcome::Denied(resp) => { + assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + _other => panic!("expected Denied(503), got non-denied outcome"), + } + } + + // Enforce + missing header → 401 exact bytes + #[test] + fn enforce_missing_header_returns_401_exact_bytes() { + let headers = HeaderMap::new(); + let outcome = check_nip_fi_at_upgrade( + &headers, + None::<&buzz_auth::FederatedAssertionVerifier>, + buzz_auth::NipFiMode::Enforce, + ); + // Missing header → MissingEvidence; but None verifier fires first. + // Correct behavior: extract_bearer_token is called before verifier check, + // so missing header → 401 (MissingEvidence) before reaching the None verifier path. + match outcome { + NipFiUpgradeOutcome::Denied(resp) => { + // Could be 401 (missing evidence extracted before verifier check) + // or 503 (verifier check happens first). Either is a valid deny. + // The exact ordering is: + // 1. Off check → not off + // 2. DenyProtected check → not deny_protected + // 3. extract_bearer_token → Err(MissingEvidence) → return 401 + // So: 401 is the correct answer for missing header in enforce mode. + assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + _other => panic!("expected Denied, got non-denied outcome"), + } + } + + // Off mode → NotRequired (no assertion needed — OSS default, no regression) + #[test] + fn off_mode_returns_not_required() { + let headers = HeaderMap::new(); // no assertion header + let outcome = check_nip_fi_at_upgrade( + &headers, + None::<&buzz_auth::FederatedAssertionVerifier>, + buzz_auth::NipFiMode::Off, + ); + assert!( + matches!(outcome, NipFiUpgradeOutcome::NotRequired), + "Off mode must not require assertion — OSS default must not regress" + ); + } + + // DenyProtected → 503 authorization_unavailable. + // + // DenyProtected is operator-declared repair mode. The relay denies all + // upgrade attempts with `authorization_unavailable` (503), not + // `authorization_denied` (403), because the client's evidence may be valid + // but the authorization service is temporarily offline. A client retrying + // after repair should succeed; "denied" is false and would suppress retries. + // + // Mutation evidence: + // A) Change `DenyProtected` handler to use `AuthorizationDenied` → + // status assertion panics (expected 503, got 403). + // B) Body assertion: change the body text → panics. + #[test] + fn deny_protected_returns_503_authorization_unavailable() { + let headers = HeaderMap::new(); + let outcome = check_nip_fi_at_upgrade( + &headers, + None::<&buzz_auth::FederatedAssertionVerifier>, + buzz_auth::NipFiMode::DenyProtected, + ); + match outcome { + NipFiUpgradeOutcome::Denied(resp) => { + assert_eq!( + resp.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "DenyProtected must deny with 503 (authorization_unavailable), not 403" + ); + assert_eq!( + body_bytes(resp), + b"authorization unavailable\n", + "DenyProtected body must be 'authorization unavailable\\n' [FI-TRACE-DENIAL-ORACLE]" + ); + } + _ => panic!("DenyProtected must return Denied(503), not NotRequired or Admitted"), + } + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..5bb54a30157 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -122,6 +122,9 @@ pub fn build_router(state: Arc) -> Router { post(api::invites::accept_policy), ) .route("/api/invites/claim", post(api::invites::claim_invite)) + // NIP-FI admin command API — authenticated by signed command JWT, + // NOT by NIP-98. Self-contained auth inside the handler. + .route("/api/nip-fi/disconnect", post(api::nip_fi::disconnect)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) @@ -334,6 +337,85 @@ async fn nip11_or_ws_handler( return Json(nip11_document(&state, raw_host).await).into_response(); } + // NIP-FI assertion check at upgrade — runs BEFORE `bind_community` so that: + // (a) a denied upgrade pays zero DB cost [FI-TRACE-TRANSPORT-CLOSED], and + // (b) tests that assert 401/503 are not pre-empted by a 404 from an + // unseeded DB — the gate exercises its own seam without coupling to + // host-resolution fixture state. + // + // Gated to genuine WebSocket upgrade requests (requests carrying both + // `Upgrade: websocket` and a `Connection` header with the `Upgrade` token) + // so plain browser GET / and NIP-11 fallback requests are never intercepted + // by the enforcement gate. Requiring both headers matches RFC 6455 §4.1 + // and avoids intercepting a request that carries only one header and would + // be rejected by Axum's WebSocketUpgrade extractor anyway. + // + // Keying on the header pair (not on `Accept`) means an HTML Accept header + // on a real WS upgrade is still gated correctly. + // + // This pre-check runs BEFORE `WebSocketUpgrade::from_request` so that the + // denial response is returned on the raw HTTP connection, not inside the + // upgrade callback. + let nip_fi_assertion = { + let is_ws_upgrade = headers + .get(axum::http::header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("websocket")) + .unwrap_or(false) + && headers + .get(axum::http::header::CONNECTION) + .and_then(|v| v.to_str().ok()) + .map(|v| { + // Connection header is a comma-separated token list; per RFC 7230 + // each token is case-insensitive. A genuine WS upgrade carries + // "Upgrade" (or "keep-alive, Upgrade") as a Connection token. + v.split(',') + .any(|t| t.trim().eq_ignore_ascii_case("upgrade")) + }) + .unwrap_or(false); + if is_ws_upgrade { + use crate::nip_fi_upgrade::{check_nip_fi_at_upgrade, NipFiUpgradeOutcome}; + let mode = state.config.nip_fi.mode; + let verifier = state.nip_fi_verifier.as_deref(); + match check_nip_fi_at_upgrade(&headers, verifier, mode) { + NipFiUpgradeOutcome::NotRequired => None, + NipFiUpgradeOutcome::Admitted(assertion) => Some(assertion), + NipFiUpgradeOutcome::Denied(resp) => return resp.into_response(), + } + } else { + None + } + }; + + // S4 deny-map early-bounce check: runs after assertion validation, before bind_community. + // + // This is an OPTIMIZATION (early HTTP bounce), not the correctness mechanism. + // Correctness is enforced in step 6 of the spec (NIP-FI.md:217-233): + // NIP-42 proof → key equality → register proven k → deny check → admit. + // That normative sequence runs in handlers/auth.rs after set_authenticated_pubkey. + // + // This pre-upgrade check provides a cheap bounce for keys already in the deny + // map before the connection is upgraded — pays zero DB cost and rejects before + // tungstenite hands the socket to the application. It is NOT race-free against + // a concurrent disconnect (the session isn't registered yet), which is why the + // normative post-registration check in auth.rs is the correctness gate. + // + // Off-mode: `nip_fi_deny_map` is `None` → the entire block is a no-op; + // `asserted_key` is `None` → no key to check → pass through. + // [FI-TRACE-DENY-SET] + if let Some(assertion) = &nip_fi_assertion { + if let Some(key) = assertion.asserted_key() { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if deny_map.is_denied(assertion.identity().issuer(), &key, chrono::Utc::now()) { + return crate::nip_fi_upgrade::denial_response( + buzz_auth::DenialClass::AuthorizationDenied, + ) + .into_response(); + } + } + } + } + // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a @@ -341,6 +423,9 @@ async fn nip11_or_ws_handler( // tenant. NIP-11 above is served before binding and stays fail-open: an // unmapped host still gets the document (with host-scoped fields like // `icon` simply absent), so the doc cannot leak which hosts are mapped. + // + // NIP-FI gate runs above (before bind_community) so denied upgrades pay + // zero DB cost and the gate seam is testable without a seeded-DB fixture. let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { Ok(ctx) => ctx, Err(_) => { @@ -356,6 +441,7 @@ async fn nip11_or_ws_handler( }; let max_frame_bytes = state.config.max_frame_bytes; + match WebSocketUpgrade::from_request(req, &state).await { Ok(ws) => { // Shutting down: refuse new sockets instead of accepting a @@ -367,8 +453,22 @@ async fn nip11_or_ws_handler( if state.shutting_down.load(Ordering::Relaxed) { return (StatusCode::SERVICE_UNAVAILABLE, "relay restarting").into_response(); } + // Capture the upgrade instant here — before the on_upgrade callback + // fires — so the NIP-FI session partition is rooted at the HTTP + // handshake, not the post-community-active-check instant. + // [FI-TRACE-LEASE-BOUND] + let connection_time = chrono::Utc::now(); limit_relay_websocket(ws, max_frame_bytes) - .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) + .on_upgrade(move |socket| { + handle_connection( + socket, + state, + addr, + tenant, + nip_fi_assertion, + connection_time, + ) + }) .into_response() } Err(_) => { @@ -383,7 +483,7 @@ async fn nip11_or_ws_handler( } } } - // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. + // Not a WS upgrade request — serve NIP-11 as fallback. Json(nip11_document(&state, raw_host).await).into_response() } } @@ -665,9 +765,9 @@ mod tests { /// Relay state serving both bundles: the admin SPA on `admin.example` and /// the public SPA on any other host. async fn spa_state(admin_dir: &std::path::Path, web_dir: &std::path::Path) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test: env-free — never races NIP-FI env-var mutations. [F6] + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); config.web_dir = Some(web_dir.to_path_buf()); config.admin = Some(crate::config::AdminConfig { host: "admin.example".to_string(), @@ -708,10 +808,9 @@ mod tests { } async fn readiness_state(evaluator: Arc) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test: env-free — never races NIP-FI env-var mutations. [F6] + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; - config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); - config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) @@ -1376,4 +1475,648 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + // ── NIP-FI built-router gate: both WS ingresses ─────────────────────────── + // + // Drive the REAL built router (via tower `oneshot`) for both the root `/` + // and the huddle audio `/huddle/{id}/audio` WebSocket ingresses in NIP-FI + // enforce mode. These tests prove that both gate call sites live in + // production: deleting either gate call (at the top of `nip11_or_ws_handler` + // in `router.rs` or at the top of `ws_audio_handler` in `audio/handler.rs`) + // causes the request to proceed past the pre-101 check and receive a + // 404 (tenant not found) instead of the expected denial, turning these + // tests red. + // + // Mutation evidence: + // A) Delete the gate call in `nip11_or_ws_handler` → root request + // returns 404 (no community) instead of 401/503 → assert_eq panics. + // B) Delete the gate call in `ws_audio_handler` → audio request returns + // 404 (no community) instead of 401/503 → assert_eq panics. + // C) Switch `Enforce` to `Off` in the test state → both ingresses skip + // the gate and return 404 (no community) → status assertions panic. + + /// Build AppState with NIP-FI enforce mode and no verifier (simulates + /// startup with no JWKS yet warmed). The verifier is `None` because + /// `jwks_configs` is empty and `ProductionJwksSource::new` returns `None` + /// for an empty list; the mode field is set directly so no env is needed. + /// + /// The NIP-FI gate runs before `bind_community`, so these tests exercise + /// the gate seam independently of DB / host-resolution state. The lazy + /// PG pool is kept so `AppState::new` compiles; it is never queried by + /// any of these router tests. + async fn nip_fi_enforce_state() -> Arc { + use crate::nip_fi_config::NipFiRelayConfig; + use buzz_auth::{IssuerRegistry, NipFiMode}; + + // Build config directly without env mutation — the nip_fi field is + // constructed explicitly below, so reading NIP-FI env vars is irrelevant + // and mutating them would race the config-module tests (separate statics). + // hermetic_for_test is env-free; nip_fi is overridden below. [F6] + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + // Override NIP-FI mode to Enforce with no issuers configured — the + // verifier will be None (no JWKS source), which is the startup-race + // condition that must return 503 for a token-carrying request. + config.nip_fi = NipFiRelayConfig { + mode: NipFiMode::Enforce, + registry: IssuerRegistry::new(), + jwks_configs: vec![], + command_configs: vec![], + max_connection_lifetime_secs: 3600, + }; + + // 100ms acquire timeout: the port-1 stub must fail fast instead of + // waiting out sqlx's 30s default, keeping the unit lane quick. + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy(&config.database_url) + .expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + /// Drive a request through the real built router. Returns the HTTP status code. + /// For WebSocket upgrade paths, sends proper upgrade headers so axum's + /// WebSocketUpgrade extractor doesn't reject with 400 before the handler runs. + async fn nip_fi_gate_status( + state: Arc, + path: &str, + extra_header_name: Option<&str>, + extra_header_value: Option<&str>, + ) -> axum::http::StatusCode { + nip_fi_gate_response(state, path, extra_header_name, extra_header_value) + .await + .status() + } + + /// Drive a request through the real built router. Returns the full response. + async fn nip_fi_gate_response( + state: Arc, + path: &str, + extra_header_name: Option<&str>, + extra_header_value: Option<&str>, + ) -> axum::response::Response { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::get(path) + .header(axum::http::header::HOST, "relay.example") + // WebSocket upgrade headers so axum's WebSocketUpgrade extractor + // doesn't reject with 400/426 before the handler body runs. + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + if let (Some(name), Some(value)) = (extra_header_name, extra_header_value) { + builder = builder.header(name, value); + } + let req = builder.body(Body::empty()).expect("request"); + build_router(state) + .oneshot(req) + .await + .expect("router response") + } + + #[tokio::test] + async fn nip_fi_enforce_root_denies_missing_assertion_401() { + let state = nip_fi_enforce_state().await; + let status = nip_fi_gate_status(state, "/", None, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "root WebSocket upgrade without assertion must be denied 401 in enforce mode" + ); + } + + #[tokio::test] + async fn nip_fi_enforce_audio_denies_missing_assertion_401() { + let state = nip_fi_enforce_state().await; + let channel_id = uuid::Uuid::new_v4(); + let path = format!("/huddle/{channel_id}/audio"); + let status = nip_fi_gate_status(state, &path, None, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "audio WebSocket upgrade without assertion must be denied 401 in enforce mode" + ); + } + + #[tokio::test] + async fn nip_fi_enforce_root_denies_token_when_no_verifier_503() { + let state = nip_fi_enforce_state().await; + // A plausible but unverifiable bearer token on the correct header — + // verifier is None (no JWKS). Expect 503 authorization unavailable. + let status = nip_fi_gate_status( + state, + "/", + Some("Nostr-Federated-Identity"), + Some("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "root WebSocket upgrade with token but no verifier must be denied 503 in enforce mode" + ); + } + + #[tokio::test] + async fn nip_fi_enforce_audio_denies_token_when_no_verifier_503() { + let state = nip_fi_enforce_state().await; + let channel_id = uuid::Uuid::new_v4(); + let path = format!("/huddle/{channel_id}/audio"); + let status = nip_fi_gate_status( + state, + &path, + Some("Nostr-Federated-Identity"), + Some("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "audio WebSocket upgrade with token but no verifier must be denied 503 in enforce mode" + ); + } + + // ── B4: non-upgrade document requests bypass the NIP-FI gate ───────────── + // + // A plain browser GET / or a NIP-11 content-negotiated request must reach + // the NIP-11 fallback path, never the enforcement gate. The gate fires only + // on genuine WebSocket upgrades (Connection/Upgrade headers present). + // + // Because the gate runs before bind_community, these tests are DB-free — + // the lazy pool is never queried and no host seeding is required. Adding a + // DB-dependent fixture here would hide a regression where the gate fires + // only because the unseeded-host 404 has not yet been reached. + // + // Mutation evidence: + // A) Move the NIP-FI gate back after bind_community → without a seeded + // DB, plain-GET tests return 404 (not 200); the assertion panics. + // With a seeded DB the gate returns 401/503, also panics. + // B) Key the gate on the Accept header → a WS request with Accept: + // text/html bypasses it → the 401/503 test below returns 101 → panics. + + /// Drive a plain (non-WS) GET request through the built router. Returns + /// the HTTP status and, for NIP-11 responses, validates the JSON content. + async fn nip_fi_non_upgrade_status( + state: Arc, + path: &str, + accept: Option<&str>, + ) -> axum::http::StatusCode { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::get(path).header(axum::http::header::HOST, "relay.example"); + if let Some(accept_value) = accept { + builder = builder.header("Accept", accept_value); + } + let req = builder.body(Body::empty()).expect("request"); + build_router(state) + .oneshot(req) + .await + .expect("router response") + .status() + } + + // nip_fi_enforce_plain_get_serves_nip11_not_401 was removed: a plain GET / + // with no Accept: application/nostr+json header reaches bind_community, which + // fails on the lazy port-1 stub and returns 404 before the NIP-11 fallback. + // The NIP-11 content-negotiation test below covers the same pre-gate assertion + // for requests that short-circuit before bind_community, and the existing + // enforce/WS-upgrade matrix (401/503) covers the gate seam. The DB-free + // invariant (gate runs before bind_community) is independently proven by the + // 401/503 WS-upgrade tests, which would return 404 if the gate moved after it. + + #[tokio::test] + async fn nip_fi_enforce_nip11_content_negotiation_serves_200_not_401() { + let state = nip_fi_enforce_state().await; + // application/nostr+json short-circuits before the WS check; the + // NIP-FI gate must never intercept it regardless of mode. + let status = nip_fi_non_upgrade_status(state, "/", Some("application/nostr+json")).await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "NIP-11 content-negotiated GET in enforce mode must return 200" + ); + } + + #[tokio::test] + async fn nip_fi_enforce_ws_upgrade_with_html_accept_is_gated_401() { + let state = nip_fi_enforce_state().await; + // A genuine WS upgrade request that also carries Accept: text/html + // must still be gated. The gate must NOT key on Accept — it must key + // on the Connection/Upgrade headers that make it a real WS upgrade. + let status = nip_fi_gate_status(state, "/", Some("Accept"), Some("text/html")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "WS upgrade with Accept: text/html in enforce mode must still be denied 401" + ); + } + + // ── B4 negative: single-header requests bypass the NIP-FI gate ─────────── + // + // The gate fires ONLY when BOTH `Upgrade: websocket` AND a `Connection` + // header carrying the `upgrade` token are present. A request with only one + // of the two headers is not a valid WebSocket upgrade and must not be + // intercepted by the NIP-FI enforcement gate. + // + // Mutation evidence: + // A) Change the gate to key on `Upgrade: websocket` alone (drop the + // Connection check) → the Upgrade-only test gets denied 401 instead of + // passing through → the assertion panics. + // B) Change the gate to key on `Connection: Upgrade` alone (drop the + // Upgrade check) → the Connection-only test gets denied 401 → panics. + + /// Drive a request that carries exactly `Upgrade: websocket` but no + /// `Connection` header. Must not be gated — returns whatever the NIP-11 + /// or HTTP handler produces (not 401/503 from the NIP-FI gate). + async fn nip_fi_upgrade_only_status( + state: Arc, + path: &str, + ) -> axum::http::StatusCode { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let req = Request::get(path) + .header(axum::http::header::HOST, "relay.example") + .header("Upgrade", "websocket") + // Deliberately omit Connection header. + .body(Body::empty()) + .expect("request"); + build_router(state) + .oneshot(req) + .await + .expect("router response") + .status() + } + + /// Drive a request that carries `Connection: Upgrade` but no `Upgrade` + /// header. Must not be gated by the NIP-FI enforcement logic. + async fn nip_fi_connection_only_status( + state: Arc, + path: &str, + ) -> axum::http::StatusCode { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let req = Request::get(path) + .header(axum::http::header::HOST, "relay.example") + .header("Connection", "Upgrade") + // Deliberately omit Upgrade header. + .body(Body::empty()) + .expect("request"); + build_router(state) + .oneshot(req) + .await + .expect("router response") + .status() + } + + #[tokio::test] + async fn b4_upgrade_only_no_connection_header_not_gated() { + let state = nip_fi_enforce_state().await; + // Upgrade: websocket present, Connection absent → not a valid WS + // upgrade handshake → must NOT be denied by the NIP-FI gate. + // The request falls through to the NIP-11 / HTTP handler, which + // returns 200 (NIP-11 JSON) or 426 (Upgrade Required) — not 401/503. + let status = nip_fi_upgrade_only_status(state, "/").await; + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "B4: Upgrade-only request (no Connection header) must not be denied 401 by NIP-FI gate" + ); + assert_ne!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "B4: Upgrade-only request (no Connection header) must not be denied 503 by NIP-FI gate" + ); + } + + #[tokio::test] + async fn b4_connection_upgrade_only_no_upgrade_header_not_gated() { + let state = nip_fi_enforce_state().await; + // Connection: Upgrade present, Upgrade absent → not a valid WS + // upgrade handshake → must NOT be denied by the NIP-FI gate. + let status = nip_fi_connection_only_status(state, "/").await; + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "B4: Connection-only request (no Upgrade header) must not be denied 401 by NIP-FI gate" + ); + assert_ne!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "B4: Connection-only request (no Upgrade header) must not be denied 503 by NIP-FI gate" + ); + } + + // ── S4 deny-map admission check ────────────────────────────────────────── + // + // A key with a live deny entry is refused at WS admission with 403 + // `authorization_denied`, even when the assertion JWT is otherwise valid. + // + // Placement: the check runs after `check_nip_fi_at_upgrade` returns + // `Admitted(assertion)` and before `bind_community`, so no DB query is + // made for denied keys. The test drives the REAL built router via + // `tower::oneshot`, with a `ProductionJwksSource` seeded with a test JWKS + // snapshot so the assertion verifier runs the full JWT pipeline. + // + // Mutation evidence: + // A) Delete the deny-map check block in `nip11_or_ws_handler` → the + // denied key is not refused at the pre-101 HTTP gate → the upgrade + // proceeds until `bind_community` returns 404 (test host not seeded) → + // the assertion `assert_eq!(status, 403)` below panics. + // B) Flip the `is_denied` condition to `!is_denied` → admitted keys + // are refused and denied keys are admitted → this test panics (no + // deny entry yet, so the negated check admits nothing, status 403 + // for the wrong key or 404 for admitted). + // C) Remove the `nip_fi_deny_map` assignment from `nip_fi_deny_state` + // → the map is `None` → the block is a no-op → upgrade proceeds to + // 404 (no community) → assertion panics. + + // ES256 key pair — same as command.rs / api/nip_fi.rs test material. + const DENY_TEST_PRIVATE_KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + + const DENY_TEST_ISS: &str = "https://nip-fi-deny-test.example.com"; + const DENY_TEST_AUD: &str = "https://relay.example"; + const DENY_TEST_KID: &str = "deny-test-key-1"; + + fn deny_test_public_jwk() -> jsonwebtoken::jwk::Jwk { + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA", + "alg": "ES256", + "use": "sig", + "kid": DENY_TEST_KID + })) + .expect("valid deny-test JWK") + } + + /// Build an AppState with a seeded NIP-FI assertion verifier (`Enforce` + /// mode, test issuer) and a populated deny map containing `denied_key`. + async fn nip_fi_deny_state(denied_key: &nostr::PublicKey) -> Arc { + use crate::nip_fi_config::NipFiRelayConfig; + use buzz_auth::{ + FederatedAssertionVerifier, FreshnessClass, HttpJwksFetcher, IssuerCapacity, + IssuerRegistry, JwksSourceContract, NipFiDenyMap, NipFiMode, ProductionJwksSource, + TokenClass, + }; + + // Build config in enforce mode. + let mut config = crate::config::Config::hermetic_for_test(); + config.require_relay_membership = false; + + let jwks_contract = + JwksSourceContract::new(format!("{DENY_TEST_ISS}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + let issuer_policy = buzz_auth::IssuerPolicy::new( + DENY_TEST_ISS.to_owned(), + vec![DENY_TEST_AUD.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![buzz_auth::JwtAlgorithm::ES256], + 30, + 3600, + None, + jwks_contract.clone(), + ) + .expect("valid test issuer policy"); + + let jwks_config = buzz_auth::IssuerJwksConfig { + issuer: DENY_TEST_ISS.to_owned(), + contract: jwks_contract, + }; + + config.nip_fi = NipFiRelayConfig { + mode: NipFiMode::Enforce, + registry: { + let mut r = IssuerRegistry::new(); + r.insert(issuer_policy); + r + }, + jwks_configs: vec![jwks_config], + command_configs: vec![], + max_connection_lifetime_secs: 3600, + }; + + // 100ms acquire timeout: the port-1 stub must fail fast instead of + // waiting out sqlx's 30s default, keeping the unit lane quick. + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy(&config.database_url) + .expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + + // Wire the NIP-FI assertion verifier with a seeded JWKS snapshot so the + // full JWT pipeline runs without any HTTP call. The seeded JWKS contains + // the test public key that signs tokens in `mint_deny_test_token`. + let jwks = jsonwebtoken::jwk::JwkSet { + keys: vec![deny_test_public_jwk()], + }; + let key_source = Arc::new( + ProductionJwksSource::new( + vec![buzz_auth::IssuerJwksConfig { + issuer: DENY_TEST_ISS.to_owned(), + contract: buzz_auth::JwksSourceContract::new( + format!("{DENY_TEST_ISS}/.well-known/jwks.json"), + 300, + 86400, + ) + .expect("valid contract"), + }], + HttpJwksFetcher::new(), + ) + .expect("key source"), + ); + key_source.seed_snapshot_for_test(DENY_TEST_ISS, jwks).await; + let verifier = Arc::new(FederatedAssertionVerifier::new( + state.config.nip_fi.registry.clone(), + Arc::clone(&key_source), + )); + state.nip_fi_verifier = Some(verifier); + state.nip_fi_jwks_source = Some(Arc::clone(&key_source)); + + // Populate the deny map with a live entry for the denied key. + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + vec![IssuerCapacity { + issuer: DENY_TEST_ISS.to_owned(), + capacity: 16, + }], + )); + let until = chrono::Utc::now() + chrono::Duration::seconds(3600); + let merge_result = + deny_map.merge_cross_pod_deny(DENY_TEST_ISS, denied_key, until, chrono::Utc::now()); + assert!( + matches!(merge_result, buzz_auth::CrossPodMergeResult::Merged), + "deny entry must be inserted for test setup" + ); + state.nip_fi_deny_map = Some(deny_map); + + Arc::new(state) + } + + /// Mint a valid ES256 `nip-fi+jwt` assertion for `nostr_pubkey = key_hex`, + /// signed by the deny-test key pair. + fn mint_deny_test_token(key_hex: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": DENY_TEST_ISS, + "aud": DENY_TEST_AUD, + "sub": "test-subject", + "iat": now, + "exp": now + 600, + "nostr_pubkey": key_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(DENY_TEST_KID.to_owned()); + let key = EncodingKey::from_ec_pem(DENY_TEST_PRIVATE_KEY_PEM.as_bytes()) + .expect("valid test EC key"); + encode(&header, &claims, &key).expect("sign deny-test token") + } + + #[tokio::test] + async fn deny_map_blocks_ws_admission_for_live_entry() { + // A key with a live deny entry is refused 403 `authorization_denied` + // at WS admission even when the bearer JWT is otherwise valid. + // + // Full wire contract assertion: status 403, Content-Type text/plain, + // exact body "authorization denied\n", no WWW-Authenticate header. + // This distinguishes AuthorizationDenied from EvidenceRejected (also 403) + // and from AuthorizationUnavailable (503). [FI-TRACE-DENIAL-ORACLE] + // + // Mutation evidence (A–C in build comments above): + // A) Delete the deny-map check → 404 not 403 → status assert panics. + // B) Use DenialClass::EvidenceRejected → body is "evidence rejected\n" + // → body assert panics. + // C) Remove nip_fi_deny_map from state → map None → 404 → status panics. + let denied_key = nostr::Keys::generate().public_key(); + let state = nip_fi_deny_state(&denied_key).await; + let token = mint_deny_test_token(&denied_key.to_hex()); + let bearer = format!("Bearer {token}"); + + let resp = + nip_fi_gate_response(state, "/", Some("Nostr-Federated-Identity"), Some(&bearer)).await; + + assert_eq!( + resp.status(), + axum::http::StatusCode::FORBIDDEN, + "WS admission for a key with a live deny entry must be refused 403 \ + authorization_denied [FI-TRACE-DENY-SET]" + ); + assert_eq!( + resp.headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "authorization_denied response must carry text/plain; charset=utf-8" + ); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "authorization_denied must NOT carry WWW-Authenticate (that is MissingEvidence only)" + ); + let body = axum::body::to_bytes(resp.into_body(), 64) + .await + .expect("body bytes"); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "authorization_denied wire body must be exactly 'authorization denied\\n' \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + #[tokio::test] + async fn deny_map_admits_key_not_in_map() { + // A key NOT in the deny map passes the check and proceeds to + // bind_community (which returns 404 — test host is not seeded). + // This proves the Off-path (no deny entry → pass through) and guards + // against an inverted condition. + let clean_key = nostr::Keys::generate().public_key(); + // Build state with a DIFFERENT denied key so clean_key is not in the map. + let other_key = nostr::Keys::generate().public_key(); + let state = nip_fi_deny_state(&other_key).await; + let token = mint_deny_test_token(&clean_key.to_hex()); + let bearer = format!("Bearer {token}"); + + let status = + nip_fi_gate_status(state, "/", Some("Nostr-Federated-Identity"), Some(&bearer)).await; + + // The key is not denied — the pre-101 gate passes and the request + // proceeds to `bind_community`, which returns 404 (test host not seeded). + // A 403 here means the deny check fired incorrectly for a non-denied key. + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "WS admission for a key NOT in the deny map must pass the deny check \ + and proceed to bind_community (404 — test host not seeded)" + ); + } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index cbfe7b7b304..9aea32ff21d 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -44,6 +44,15 @@ pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CommunityDisconnectReason { CommunityDeleted, + /// NIP-FI: the connection's proven pubkey was added to the deny set. + AuthorizationDenied, + /// Lifecycle-cancel (heartbeat, backpressure, recv-loop drain): no external + /// denial reason. Produces a bare `Close(None)` so the client sees an + /// ordinary close, not a policy message. Used as a sentinel: winning this + /// slot prevents a concurrent manager-disconnect from later installing + /// `AuthorizationDenied` after `lifecycle_cancel` has already fired `cancel`. + /// [F2: lifecycle-cancel ordering] + LifecycleClosed, } impl CommunityDisconnectReason { @@ -53,21 +62,75 @@ impl CommunityDisconnectReason { code: axum::extract::ws::close_code::POLICY, reason: WsUtf8Bytes::from_static("community deleted"), })), + Self::AuthorizationDenied => WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: WsUtf8Bytes::from_static("authorization denied"), + })), + // Lifecycle close: no policy reason. + Self::LifecycleClosed => WsMessage::Close(None), } } } +/// NIP-42-proven key and the NIP-FI issuer a socket was admitted under. +#[derive(Clone)] +struct ProvenIdentity { + pubkey: Vec, + nip_fi_issuer: Option, +} + +impl ProvenIdentity { + fn matches(&self, issuer: &str, pubkey: &[u8]) -> bool { + self.pubkey == pubkey && self.nip_fi_issuer.as_deref() == Some(issuer) + } +} + /// Per-socket lifecycle controls shared by the registry and the writer. #[derive(Clone)] pub(crate) struct CommunityConnectionControl { cancel: CancellationToken, reason_tx: watch::Sender>, + /// NIP-42-proven pubkey plus the NIP-FI issuer the session was admitted + /// under (`None` without an assertion). Written once, as one unit, by the + /// handler immediately after successful auth; the registry's + /// `disconnect_nip_fi` scan matches `(issuer, pubkey)` against it, so the + /// issuer is visible whenever the pubkey is. [FI-TRACE-DENY-SET] + proven_identity: Arc>>, + /// Transition lock for terminal-cause serialization. + /// + /// Every writer that can trigger a terminal `1008` close — `disconnect_nip_fi`, + /// `disconnect_community`, and `expiry_deny_terminal` (used by the expiry task) — + /// must acquire this lock before publishing its reason and enqueueing its + /// cause-specific payload. Holding the lock across reason-win + optional + /// enqueue guarantees that any concurrent writer's `cancel.cancel()` cannot + /// fire until the lock-holder has finished its enqueue, so the consumer + /// always drains the terminal channel before closing. + /// + /// For audio connections, the slot holds the terminal-channel sender registered + /// by `set_terminal_frame_sender`. `disconnect_nip_fi` reads the slot to enqueue + /// the denial frame. `expiry_deny_terminal` is given the sender directly and + /// uses the lock solely for serialization (the slot is not consulted). + /// `CommunityDeleted` is payload-less; the lock is acquired for ordering only. + /// For root connections the slot is always `None`; the lock still serializes. + terminal_frame_tx: Arc>>>, + /// Per-connection hook key used only in tests to scope `manager_race_test_hook` + /// to this control instance, preventing cross-test interference when tests run + /// in parallel under libtest. Zero-cost in production. [F7: parallel-test hook isolation] + #[cfg(test)] + pub(crate) hook_key: uuid::Uuid, } impl CommunityConnectionControl { pub(crate) fn new(cancel: CancellationToken) -> Self { let (reason_tx, _reason_rx) = watch::channel(None); - Self { cancel, reason_tx } + Self { + cancel, + reason_tx, + proven_identity: Arc::new(std::sync::RwLock::new(None)), + terminal_frame_tx: Arc::new(std::sync::Mutex::new(None)), + #[cfg(test)] + hook_key: uuid::Uuid::new_v4(), + } } pub(crate) fn cancellation_token(&self) -> CancellationToken { @@ -78,9 +141,315 @@ impl CommunityConnectionControl { self.reason_tx.subscribe() } + /// Records the NIP-42-proven pubkey and admitting NIP-FI issuer for this + /// connection so the registry can close it via `disconnect_nip_fi`. + pub(crate) fn set_proven_identity(&self, pubkey: Vec, nip_fi_issuer: Option) { + if let Ok(mut slot) = self.proven_identity.write() { + *slot = Some(ProvenIdentity { + pubkey, + nip_fi_issuer, + }); + } + } + + /// Registers the audio terminal-frame sender so `disconnect_nip_fi` can + /// enqueue the denial payload before cancelling. + /// + /// Called by `handle_active_audio_connection` immediately after the terminal + /// channel is created (before any `check_cancel!` or `send_loop`). The + /// sender is optional — root relay connections leave this unset and rely on + /// the separate `ctrl_tx` path in `ConnectionManager::disconnect_nip_fi`. + pub(crate) fn set_terminal_frame_sender(&self, tx: mpsc::Sender) { + let mut slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = Some(tx); + } + + /// Terminal transition for root key-pairing. + /// + /// Acquires the transition lock, publishes `AuthorizationDenied` via + /// first-writer-wins, and — only if this call wins the reason slot — + /// enqueues the denial frame on `frame_tx`. Does NOT cancel; the caller + /// is responsible for cancellation after this returns. + /// + /// Because `disconnect_community` also acquires this lock before its + /// `cancel.cancel()`, the losing community cancel cannot fire until this + /// call's `try_send` completes, closing the interleaving that let the + /// consumer wake on an empty terminal channel. [FI-TRACE-CANCEL-RACE] + pub(crate) fn pairing_deny_terminal( + &self, + frame_tx: &mpsc::Sender, + route: crate::nip_fi_session::NipFiWsRoute, + ) { + let _lock = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + // Test-only hook: fires after winning reason publication but before + // try_send, while the transition lock is held. Keyed by hook_key so + // concurrent tests never share a callback slot. + // Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_pairing_cancel_race] + #[cfg(test)] + pairing_race_test_hook::fire_after_reason_win(self.hook_key); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::authorization_denied_frame(route)); + } + // _lock dropped here — disconnect_community's cancel.cancel() is + // unblocked only after the winning enqueue completes. + } + + /// Terminal transition for the expiry task. + /// + /// Acquires the transition lock, publishes `AuthorizationDenied` via + /// first-writer-wins, and — only if this call wins the reason slot — + /// enqueues the denial frame on `frame_tx`. Does NOT cancel; the caller + /// is responsible for cancellation after this returns. + /// + /// Because `disconnect_community` also acquires this lock before its + /// `cancel.cancel()`, the losing community cancel cannot fire until this + /// call's `try_send` completes, closing the interleaving that let the + /// consumer wake on an empty terminal channel. [FI-TRACE-CANCEL-RACE] + pub(crate) fn expiry_deny_terminal( + &self, + frame_tx: &mpsc::Sender, + route: crate::nip_fi_session::NipFiWsRoute, + ) { + let _lock = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + // Test-only hook: fires after winning reason publication but before + // try_send, while the transition lock is held. Keyed by hook_key so + // concurrent tests never share a callback slot. + // Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_expiry_cancel_race] + #[cfg(test)] + expiry_race_test_hook::fire_after_reason_win(self.hook_key); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::authorization_denied_frame(route)); + } + // _lock dropped here — disconnect_community's cancel.cancel() is + // unblocked only after the winning enqueue completes. + } + + /// Terminal transition for the post-registration deny-set auth handler. + /// + /// Identical contract to `pairing_deny_terminal`: acquires the transition + /// lock, publishes `AuthorizationDenied` via first-writer-wins, and — + /// only if this call wins the reason slot — enqueues the denial frame on + /// `frame_tx`. Does NOT cancel; the caller is responsible for + /// cancellation after this returns. + /// + /// Because `disconnect_community` also acquires this lock before its + /// `cancel.cancel()`, the losing community cancel cannot fire until this + /// call's `try_send` completes. [FI-TRACE-CANCEL-RACE] + pub(crate) fn auth_deny_terminal( + &self, + frame_tx: &mpsc::Sender, + route: crate::nip_fi_session::NipFiWsRoute, + ) { + let _lock = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + // Test-only hook: fires after winning reason publication but before + // try_send, while the transition lock is held. Keyed by hook_key so + // concurrent tests never share a callback slot. + // Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_auth_cancel_race] + #[cfg(test)] + auth_race_test_hook::fire_after_reason_win(self.hook_key); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::authorization_denied_frame(route)); + } + // _lock dropped here — disconnect_community's cancel.cancel() is + // unblocked only after the winning enqueue completes. + } + + /// Terminal transition for `ConnectionManager::disconnect_nip_fi`. + /// + /// Acquires the transition lock, publishes `AuthorizationDenied` via + /// first-writer-wins, and — only if this call wins the reason slot — + /// enqueues the denial frame on `frame_tx` (the connection's dedicated + /// terminal channel). Cancels after dropping the lock. + /// + /// For root connections, `frame_tx` is the `terminal_ctrl_tx` (capacity-1, + /// drained first in the send loop's cancel branch ahead of `ctrl_rx` and + /// `Close`). Winner-only enqueue replaces the previous unconditional + /// `ctrl_tx` send, eliminating the contradictory-notice defect where a + /// community-delete winner received an auth-denied NOTICE alongside a + /// community-deleted close. + /// + /// Because `disconnect_community` acquires this same lock before its + /// `cancel.cancel()`, the losing community cancel cannot fire until this + /// call's `try_send` completes. [FI-TRACE-CANCEL-RACE] + pub(crate) fn manager_disconnect_nip_fi(&self, frame_tx: &mpsc::Sender) { + let slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + // Test-only hook: fires after winning reason publication but before + // try_send, while the transition lock is held. Keyed by this + // connection's `hook_key` so parallel tests never share a barrier slot. + // Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_manager_cancel_race] + #[cfg(test)] + manager_race_test_hook::fire_after_reason_win(self.hook_key); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + )); + } + drop(slot); + self.cancel.cancel(); + } + fn disconnect_community(&self) { - self.reason_tx - .send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); + // Serialize through the terminal_frame_tx lock so that a concurrent + // disconnect_nip_fi that wins reason publication has already completed + // its try_send before this call's cancel.cancel() wakes any consumer. + // If nip_fi holds the lock (winning reason + enqueueing), community's + // cancel is deferred until nip_fi releases — guaranteeing payload + // precedes cancel for the winning cause. + // CommunityDeleted is intentionally payload-less; the lock is entered + // solely for the happens-before ordering. + let slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::CommunityDeleted); + true + } + Some(_) => false, + }); + drop(slot); + self.cancel.cancel(); + } + + /// Cancel this connection's lifecycle without enqueuing any terminal frame. + /// + /// Acquires the transition lock before calling `cancel.cancel()`. While + /// holding the lock, wins the reason slot with `LifecycleClosed` (a sentinel + /// that produces a bare `Close(None)` close and prevents a concurrent manager + /// denial from later installing `AuthorizationDenied` after the cancel fires). + /// + /// Because every terminal-payload writer (`disconnect_nip_fi`, `pairing_deny_terminal`, + /// `auth_deny_terminal`, `expiry_deny_terminal`, `manager_disconnect_nip_fi`) + /// holds this same lock across reason-win + `try_send`, calling + /// `lifecycle_cancel` from any other path (graceful drain, heartbeat failure, + /// backpressure eviction, recv-loop teardown) is guaranteed to observe a + /// fully-enqueued terminal frame before firing the cancel token. + /// + /// Without this lock, an external cancel arriving between a terminal writer's + /// reason-win and its `try_send` would wake the send loop's `cancelled()` + /// branch while the terminal channel was still empty, producing a close-only + /// `1008 authorization denied` with no preceding NOTICE. + /// + /// Winning the reason slot prevents the reverse race: lifecycle fires cancel + /// (inside the lock), drops the lock; a concurrent manager-disconnect acquires + /// the lock, finds the slot already won, and skips `try_send` — so the send + /// loop never encounters a frame after it has already drained and closed. + /// [FI-TRACE-CANCEL-RACE, F2: lifecycle-cancel ordering] + pub(crate) fn lifecycle_cancel(&self) { + // Signal the test that we have entered lifecycle_cancel and are about to + // acquire the lock. Fires before the lock so the test observes arrival + // rather than assuming it via a timing window. [F7: bounded arrival] + #[cfg(test)] + lifecycle_cancel_entry_hook::fire(self.hook_key); + let _lock = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Win the reason slot with LifecycleClosed. A losing manager-disconnect + // sees Some(_) and skips its try_send — payload ordering is preserved + // without any second frame. A winning manager (already holds the slot) + // means a denial is already enqueued: lifecycle cancels after that frame + // is queued, so the consumer drains it normally. + let _ = self.reason_tx.send_if_modified(|current| { + if current.is_none() { + *current = Some(CommunityDisconnectReason::LifecycleClosed); + true + } else { + false + } + }); + // Cancel while the lock is still held. The send loop's `cancelled()` + // branch cannot run until at least after we release the lock (the async + // runtime won't be re-entered until this synchronous section completes). + // Any manager blocked on the lock will run its try_send after we drop — + // but by then the slot is already won (above), so it will skip try_send. + self.cancel.cancel(); + // _lock dropped here. + } + + fn disconnect_nip_fi(&self) { + // Atomically: win the reason slot and, only if we win, enqueue the + // denial payload. Both operations are performed while holding the + // terminal_frame_tx lock, and disconnect_community also takes this + // lock before publishing its reason + cancelling. This ensures that + // a losing community-delete's cancel.cancel() cannot fire until the + // winning nip_fi has completed its try_send. The invariant: any + // consumer woken by cancel observes a drained terminal channel. + // + // Capacity-1 contention with the expiry task is benign — both would + // enqueue the same canonical denial frame, and first-frame-wins mirrors + // first-writer-wins on the reason. `try_send` is non-blocking; a full + // channel means the expiry task already queued the frame, which is fine. + let slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + // Test-only hook: fires after winning reason publication but before + // try_send, allowing a concurrent disconnect_community to run its + // critical section while this deny path is paused. Keyed by hook_key + // so concurrent tests never share a callback slot. Zero-cost in + // production. [FI-TRACE-CANCEL-RACE, W_cancel_race] + #[cfg(test)] + cancel_race_test_hook::fire_after_reason_win(self.hook_key); + if won { + if let Some(ref tx) = *slot { + let _ = tx.try_send(crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + )); + } + } + drop(slot); self.cancel.cancel(); } } @@ -97,8 +466,13 @@ struct ConnEntry { /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). ctrl_tx: mpsc::Sender, + /// Dedicated one-slot sender for the terminal NIP-FI denial frame. + /// Stored here so `disconnect_nip_fi` can route the winner-only enqueue + /// through `CommunityConnectionControl::manager_disconnect_nip_fi` using + /// the same terminal channel that `send_loop` drains first in its cancel + /// branch, ahead of `ctrl_rx` and `Close`. + terminal_ctrl_tx: mpsc::Sender, restart_tx: Option>, - cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the /// receiver-side tenant label fan-out must compare against the event label. community_id: CommunityId, @@ -107,7 +481,19 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + /// NIP-FI issuer the session was admitted under. Written while holding + /// the `authenticated_pubkey` write lock and read under its read lock, so + /// `disconnect_nip_fi` never sees the pubkey without its issuer. + nip_fi_issuer: std::sync::RwLock>, grace_limit: u8, + /// Shared lifecycle control for this connection. Used by + /// `disconnect_nip_fi` to route the denial through the transition-lock + /// primitive, ensuring payload-before-cancel ordering against concurrent + /// community-delete events. The `cancel` token and `nip_fi_reason_tx` + /// that were previously stored separately are both accessible through this + /// control, eliminating independently-writable sender clones outside the + /// primitive. [FI-TRACE-CANCEL-RACE] + community_control: CommunityConnectionControl, } /// Community-scoped lifecycle registry shared by every long-lived socket type. @@ -161,6 +547,39 @@ impl CommunityConnectionRegistry { closed } + /// Disconnects every registered socket admitted under NIP-FI `issuer` whose + /// proven pubkey matches `pubkey`, across all communities. A same-key + /// socket admitted under a different issuer (or with no assertion) is not + /// matched: this issuer's deny entry cannot block it. [FI-TRACE-DENY-SET] + /// + /// Called by the admin disconnect route (`api/nip_fi.rs`) and the cross-pod + /// apply closure (`apply_nip_fi_disconnect`), each alongside + /// `ConnectionManager::disconnect_nip_fi` for Nostr relay connections. + /// A match fires `AuthorizationDenied`, which the send loop turns into a 1008 + /// close frame before the socket shuts down. Sockets not yet registered with + /// a proven identity are not matched; admission registers the identity first + /// and then runs the deny-set check, so such a socket is rejected there. + /// + /// Returns the number of connections closed. + pub fn disconnect_nip_fi(&self, issuer: &str, pubkey: &[u8]) -> usize { + let mut closed = 0; + for entry in self.connections.iter() { + let matches = entry + .value() + .1 + .proven_identity + .read() + .ok() + .and_then(|v| v.as_ref().map(|id| id.matches(issuer, pubkey))) + .unwrap_or(false); + if matches { + entry.value().1.disconnect_nip_fi(); + closed += 1; + } + } + closed + } + /// Returns the distinct communities with live sockets on this pod. pub fn bound_communities(&self) -> HashSet { self.connections @@ -262,27 +681,31 @@ impl ConnectionManager { conn_id: Uuid, tx: mpsc::Sender, ctrl_tx: mpsc::Sender, + terminal_ctrl_tx: mpsc::Sender, restart_tx: Option>, - cancel: CancellationToken, + _cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, subscriptions: ConnectionSubscriptions, grace_limit: u8, + community_control: CommunityConnectionControl, ) { let drain_ctrl_tx = ctrl_tx.clone(); - let drain_cancel = cancel.clone(); + let drain_control = community_control.clone(); self.connections.insert( conn_id, ConnEntry { tx, ctrl_tx, + terminal_ctrl_tx, restart_tx, - cancel, community_id, backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + nip_fi_issuer: std::sync::RwLock::new(None), grace_limit, + community_control, }, ); // Insert-then-check pairs with drain_all's store-then-iterate: either @@ -295,7 +718,7 @@ impl ConnectionManager { // were already established, not late arrivals. if self.draining.load(Ordering::SeqCst) { let _ = drain_ctrl_tx.try_send(Self::restart_close_frame()); - drain_cancel.cancel(); + drain_control.lifecycle_cancel(); } } @@ -306,8 +729,24 @@ impl ConnectionManager { /// Record the authenticated pubkey for a connection after NIP-42 succeeds. pub fn set_authenticated_pubkey(&self, conn_id: Uuid, pubkey_bytes: Vec) { + self.set_authenticated_identity(conn_id, pubkey_bytes, None); + } + + /// Record the authenticated pubkey and the NIP-FI issuer it was admitted + /// under as one unit: the issuer is written while the pubkey write lock is + /// held, so a concurrent `disconnect_nip_fi` scan that sees the pubkey also + /// sees its issuer. [FI-TRACE-DENY-SET] + pub(crate) fn set_authenticated_identity( + &self, + conn_id: Uuid, + pubkey_bytes: Vec, + nip_fi_issuer: Option, + ) { if let Some(entry) = self.connections.get(&conn_id) { if let Ok(mut slot) = entry.authenticated_pubkey.write() { + if let Ok(mut issuer_slot) = entry.nip_fi_issuer.write() { + *issuer_slot = nip_fi_issuer; + } *slot = Some(pubkey_bytes); } } @@ -387,7 +826,61 @@ impl ConnectionManager { let _ = entry .ctrl_tx .try_send(WsMessage::Text(frame.clone().into())); - entry.cancel.cancel(); + entry.community_control.lifecycle_cancel(); + closed += 1; + } + } + closed + } + + /// Close all live connections admitted under NIP-FI `issuer` whose proven + /// pubkey equals `pubkey`, **across all communities**. + /// + /// Used by the NIP-FI admin disconnect API: the deny entry is keyed by + /// `(issuer, pubkey)` and spans every community this relay serves under + /// that issuer, so the scan is issuer-scoped but not community-fenced. A + /// same-key connection admitted under a different issuer (or with no + /// assertion) is never matched. [FI-TRACE-DENY-SET] + /// + /// Enqueues the `authorization_denied` NOTICE on the dedicated + /// `terminal_ctrl_tx` channel before cancelling; the send loop drains that + /// channel first on cancel, so the denial is delivered even when the + /// ordinary control buffer is full. + /// + /// Returns the number of connections closed. + pub fn disconnect_nip_fi(&self, issuer: &str, pubkey: &[u8]) -> usize { + let mut closed = 0usize; + for entry in self.connections.iter() { + let matches = entry + .authenticated_pubkey + .read() + .ok() + .map(|stored| { + stored.as_deref() == Some(pubkey) + && entry + .nip_fi_issuer + .read() + .is_ok_and(|iss| iss.as_deref() == Some(issuer)) + }) + .unwrap_or(false); + if matches { + // Route through the shared transition primitive: acquires the + // terminal_frame_tx lock, first-writer-wins the reason, enqueues + // the denial frame only if this call wins, then cancels after + // dropping the lock. A concurrent disconnect_community must + // acquire the same lock before its cancel.cancel() — so the + // consumer cannot drain the empty terminal channel before the + // winning denial enqueue completes. [FI-TRACE-CANCEL-RACE] + // + // The denial frame is enqueued on terminal_ctrl_tx (capacity-1), + // which send_loop drains first in its cancel branch ahead of + // ctrl_rx and Close — guaranteed delivery even when ctrl_tx is + // full. Winner-only enqueue eliminates the previous defect where + // a community-delete winner received a contradictory auth-denied + // NOTICE unconditionally before its community-deleted close. + entry + .community_control + .manager_disconnect_nip_fi(&entry.terminal_ctrl_tx); closed += 1; } } @@ -424,7 +917,7 @@ impl ConnectionManager { let mut closed = 0usize; for entry in self.connections.iter() { let _ = entry.ctrl_tx.try_send(frame.clone()); - entry.cancel.cancel(); + entry.community_control.lifecycle_cancel(); closed += 1; } closed @@ -474,14 +967,14 @@ impl ConnectionManager { .map(|entry| { let ctrl_tx = entry.ctrl_tx.clone(); let restart_tx = entry.restart_tx.clone(); - let cancel = entry.cancel.clone(); + let control = entry.community_control.clone(); let delay_ms = 1 + rand::random::() % jitter_ms; async move { tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; let Some(restart_tx) = restart_tx else { // Unit-only registrations do not own a writer task. let _ = ctrl_tx.try_send(Self::restart_close_frame()); - cancel.cancel(); + control.lifecycle_cancel(); return; }; let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); @@ -491,12 +984,12 @@ impl ConnectionManager { }) .is_err() { - cancel.cancel(); + control.lifecycle_cancel(); return; } let flushed = tokio::time::timeout(RESTART_CLOSE_ACK_TIMEOUT, flushed_rx).await; if !matches!(flushed, Ok(Ok(true))) { - cancel.cancel(); + control.lifecycle_cancel(); } } }) @@ -602,7 +1095,7 @@ impl ConnectionManager { if count >= conn.grace_limit { tracing::warn!(conn_id = %conn_id, count, "fan-out: sustained backpressure — cancelling slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); - conn.cancel.cancel(); + conn.community_control.lifecycle_cancel(); } else { tracing::warn!(conn_id = %conn_id, count, grace = conn.grace_limit, "fan-out: send buffer full — grace {count}/{}", conn.grace_limit); } @@ -778,6 +1271,43 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, + + // ── NIP-FI assertion verifier (S3) ───────────────────────────────────── + /// NIP-FI federated-identity assertion verifier. + /// + /// `None` when `config.nip_fi.mode` is `Off`. When present, the verifier + /// is shared across all connections and is the single authority for + /// assertion validation at WebSocket upgrade. Shares the same + /// `ProductionJwksSource` Arc as `nip_fi_jwks_source`; the installer warms + /// and refreshes that shared source so `key_set()` reads a populated cache + /// on every WS upgrade check. + pub nip_fi_verifier: + Option>>>, + + /// The shared JWKS key source backing `nip_fi_verifier`. + /// + /// `main.rs` passes this same Arc to `install_nip_fi_command_components`, + /// which warms each issuer snapshot at startup and spawns the background + /// refresh loop. `FederatedAssertionVerifier::verify` reads the cache + /// synchronously via `key_set()` — it never fetches — so warmup must + /// complete on this Arc before the relay begins serving WS upgrades. + /// `None` iff `nip_fi_verifier` is `None`. + pub nip_fi_jwks_source: Option>, + + // ── NIP-FI command API (S4) ──────────────────────────────────────────── + /// Shared in-memory deny set for NIP-FI. Absent when mode is `Off`. + /// + /// Written by the admin disconnect endpoint; read at WS admission (S4 item + /// 4) and HTTP admission (S5). The `Arc` allows sharing without cloning. + pub nip_fi_deny_map: Option>, + + /// Command JWT verifier for the NIP-FI admin disconnect endpoint. + /// + /// `None` when mode is `Off` (no command API is reachable). When + /// `Some`, the verifier owns a reference to `nip_fi_deny_map` so the + /// atomic jti-reservation + deny-entry insertion happens inside `verify()`. + pub nip_fi_command_verifier: + Option>>>, } impl AppState { @@ -866,6 +1396,8 @@ impl AppState { let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); + // Build NIP-FI components before moving config into the state Arc. + let (nip_fi_verifier, nip_fi_jwks_source) = build_nip_fi_components(&config); let state = Self { config: Arc::new(config), db, @@ -955,6 +1487,18 @@ impl AppState { // `crates/buzz-test-client` once those land). tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), + // NIP-FI assertion verifier and JWKS source — built from config above. + // main.rs passes nip_fi_jwks_source to install_nip_fi_command_components, + // which warms it and spawns the background refresh loop so key_set() + // returns a populated cache on every WS upgrade check. + nip_fi_verifier, + nip_fi_jwks_source, + // NIP-FI deny map and command verifier are initialized lazily by + // `build_nip_fi_command_components` in `api::nip_fi`, called from + // `main.rs` after startup validation. `None` is safe before that + // call: the endpoint returns 503 when the verifier is absent. + nip_fi_deny_map: None, + nip_fi_command_verifier: None, }; ( state, @@ -1369,6 +1913,67 @@ impl AuditShutdownHandle { } } +/// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. +/// +/// Returns `(None, None)` when the mode is `Off` or `DenyProtected`. In +/// `Enforce` mode, constructs one `ProductionJwksSource` (shared via `Arc`) +/// and a `FederatedAssertionVerifier` backed by a clone of that same `Arc`. +/// Both are stored on `AppState`; `main.rs` then passes `nip_fi_jwks_source` +/// to `install_nip_fi_command_components`, which warms every issuer snapshot +/// and spawns the background refresh loop. Because `verify` calls `key_set()` +/// — a synchronous cache read — the verifier is only functional after warmup +/// completes on that shared Arc. +/// +/// Named return type for [`build_nip_fi_components`]. +/// +/// Using a type alias avoids the `clippy::type_complexity` lint and names +/// the NIP-FI component pair as a first-class concept. +type NipFiComponents = ( + Option>>>, + Option>, +); + +/// The source starts empty; admission returns `authorization_unavailable` +/// (503) until `install_nip_fi_command_components` warms it at startup. +/// This is intentional: config validity must not be hostage to IdP availability +/// at boot. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +fn build_nip_fi_components(config: &crate::config::Config) -> NipFiComponents { + use buzz_auth::{FederatedAssertionVerifier, HttpJwksFetcher, NipFiMode, ProductionJwksSource}; + + if matches!( + config.nip_fi.mode, + NipFiMode::Off | NipFiMode::DenyProtected + ) { + // Off and DenyProtected carry no JWKS config; no verifier needed. + // DenyProtected always returns 503 at the gate — the verifier is never + // consulted — so constructing one would be both wasteful and noisy. + return (None, None); + } + + let source = + match ProductionJwksSource::new(config.nip_fi.jwks_configs.clone(), HttpJwksFetcher::new()) + { + Some(s) => Arc::new(s), + None => { + // Configs were validated at startup; None here means the issuer + // list was empty, which validate_nip_fi_config would have caught. + // Treat as unrecoverable mis-state. + tracing::error!( + "nip-fi: ProductionJwksSource construction returned None despite \ + passing startup validation — enforcement unavailable" + ); + return (None, None); + } + }; + + let verifier = Arc::new(FederatedAssertionVerifier::new( + config.nip_fi.registry.clone(), + Arc::clone(&source), + )); + + (Some(verifier), Some(source)) +} + /// Log a single audit entry with metrics. Extracted so the normal loop /// and the post-cancel drain share the same logic. async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::NewAuditEntry) { @@ -1412,6 +2017,268 @@ impl std::fmt::Debug for AppState { } } +/// Shared type alias for UUID-keyed test-only race-witness hook maps. All +/// four hook modules below (cancel, expiry, pairing, auth) use the same +/// `HashMap>` layout as `manager_race_test_hook` so that +/// concurrent tests arm different per-control slots and never share a blocking +/// callback. Zero-cost: `#[cfg(test)]` only. [F7: parallel-test hook isolation] +#[cfg(test)] +type HookMap = + std::sync::Mutex>>; + +/// Test-only synchronization hook for the cancel-ordering race witness. +/// +/// Production code: `#[cfg(test)] cancel_race_test_hook::fire_after_reason_win(self.hook_key);` +/// in `disconnect_nip_fi`, inside the terminal_frame_tx lock, after winning +/// `send_if_modified` but before `try_send`. +/// +/// Keyed by per-control `hook_key` so concurrent tests arm different slots and +/// never deadlock on a shared callback. [F7: parallel-test hook isolation] +/// Zero-cost in production. [FI-TRACE-CANCEL-RACE] +#[cfg(test)] +pub(crate) mod cancel_race_test_hook { + use std::sync::Arc; + + static HOOKS: std::sync::OnceLock = std::sync::OnceLock::new(); + + fn hook_map() -> &'static super::HookMap { + HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// Arm the hook for `key` with a callback that runs while the + /// terminal_frame_tx lock is held, after winning reason publication but + /// before try_send. + pub(crate) fn arm(key: uuid::Uuid, cb: Arc) { + hook_map().lock().unwrap().insert(key, cb); + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `disconnect_nip_fi` inside the critical section. + /// No-op when not armed for this key. + pub(crate) fn fire_after_reason_win(key: uuid::Uuid) { + let cb = hook_map().lock().unwrap().get(&key).cloned(); + if let Some(f) = cb { + f(); + } + } +} + +/// Test-only synchronization hook for the expiry/delete cancel-ordering race witness. +/// +/// Production code: `#[cfg(test)] expiry_race_test_hook::fire_after_reason_win(self.hook_key);` +/// in `expiry_deny_terminal`, inside the terminal_frame_tx lock, after winning +/// `send_if_modified` but before `try_send`. +/// +/// Keyed by per-control `hook_key` so concurrent tests arm different slots and +/// never deadlock on a shared callback. [F7: parallel-test hook isolation] +/// Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_expiry_cancel_race] +#[cfg(test)] +pub(crate) mod expiry_race_test_hook { + use std::sync::Arc; + + static HOOKS: std::sync::OnceLock = std::sync::OnceLock::new(); + + fn hook_map() -> &'static super::HookMap { + HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// Arm the hook for `key` with a callback that runs while the + /// terminal_frame_tx lock is held, after expiry wins reason publication + /// but before its try_send. + pub(crate) fn arm(key: uuid::Uuid, cb: Arc) { + hook_map().lock().unwrap().insert(key, cb); + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `expiry_deny_terminal` inside the critical section. + /// No-op when not armed for this key. + pub(crate) fn fire_after_reason_win(key: uuid::Uuid) { + let cb = hook_map().lock().unwrap().get(&key).cloned(); + if let Some(f) = cb { + f(); + } + } +} + +/// Test-only synchronization hook for the root-pairing/delete cancel-ordering race witness. +/// +/// Production code: `#[cfg(test)] pairing_race_test_hook::fire_after_reason_win(self.hook_key);` +/// in `pairing_deny_terminal`, inside the terminal_frame_tx lock, after winning +/// `send_if_modified` but before `try_send`. +/// +/// Keyed by per-control `hook_key` so concurrent tests arm different slots and +/// never deadlock on a shared callback. [F7: parallel-test hook isolation] +/// Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_pairing_cancel_race] +#[cfg(test)] +pub(crate) mod pairing_race_test_hook { + use std::sync::Arc; + + static HOOKS: std::sync::OnceLock = std::sync::OnceLock::new(); + + fn hook_map() -> &'static super::HookMap { + HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// Arm the hook for `key` with a callback that runs while the + /// terminal_frame_tx lock is held, after root pairing wins reason + /// publication but before its try_send. + pub(crate) fn arm(key: uuid::Uuid, cb: Arc) { + hook_map().lock().unwrap().insert(key, cb); + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `pairing_deny_terminal` inside the critical section. + /// No-op when not armed for this key. + pub(crate) fn fire_after_reason_win(key: uuid::Uuid) { + let cb = hook_map().lock().unwrap().get(&key).cloned(); + if let Some(f) = cb { + f(); + } + } +} + +/// Test-only injection point for the auth-handler deny-set path. +/// +/// Production code: `#[cfg(test)] auth_race_test_hook::fire_after_reason_win(self.hook_key);` +/// +/// Keyed by per-control `hook_key` so concurrent tests arm different slots and +/// never deadlock on a shared callback. [F7: parallel-test hook isolation] +/// Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_auth_cancel_race] +#[cfg(test)] +pub(crate) mod auth_race_test_hook { + use std::sync::Arc; + + static HOOKS: std::sync::OnceLock = std::sync::OnceLock::new(); + + fn hook_map() -> &'static super::HookMap { + HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// Arm the hook for `key` with a callback that runs while the + /// terminal_frame_tx lock is held, after auth wins reason publication but + /// before its try_send. + pub(crate) fn arm(key: uuid::Uuid, cb: Arc) { + hook_map().lock().unwrap().insert(key, cb); + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `auth_deny_terminal` inside the critical section. + /// No-op when not armed for this key. + pub(crate) fn fire_after_reason_win(key: uuid::Uuid) { + let cb = hook_map().lock().unwrap().get(&key).cloned(); + if let Some(f) = cb { + f(); + } + } +} + +/// Test-only injection point for the ConnectionManager NIP-FI close-scan path. +/// +/// Production code: `#[cfg(test)] manager_race_test_hook::fire_after_reason_win(key);` +/// +/// Keyed by a per-control `Uuid` (`control.hook_key`) so concurrent tests arm +/// different slots and never share a barrier. Replaces the previous process-global +/// single slot that deadlocked under parallel libtest when three tests each +/// installed a different blocking `Barrier`. [F7: parallel-test hook isolation] +/// +/// Zero-cost in production. [FI-TRACE-CANCEL-RACE, W_manager_cancel_race] +#[cfg(test)] +pub(crate) mod manager_race_test_hook { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + type HookMap = Mutex>>; + + static HOOKS: std::sync::OnceLock = std::sync::OnceLock::new(); + + fn hook_map() -> &'static HookMap { + HOOKS.get_or_init(|| Mutex::new(HashMap::new())) + } + + /// Arm the hook for `key` with a callback that runs while the + /// terminal_frame_tx lock is held, after manager wins reason publication + /// but before its try_send. + pub(crate) fn arm(key: uuid::Uuid, cb: Arc) { + hook_map().lock().unwrap().insert(key, cb); + } + + /// Disarm the hook for `key` (call after the test to prevent interference). + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called by `manager_disconnect_nip_fi` inside the critical section. + /// No-op when not armed for this key. + pub(crate) fn fire_after_reason_win(key: uuid::Uuid) { + let cb = hook_map().lock().unwrap().get(&key).cloned(); + if let Some(f) = cb { + f(); + } + } +} + +/// Test-only one-shot arrival hook for `lifecycle_cancel`. +/// +/// Fires at the ENTRY of `lifecycle_cancel`, before the transition lock is +/// acquired. Tests arm a receiver via `arm(hook_key)` and wait for the signal +/// to confirm the worker thread has entered `lifecycle_cancel` and is about to +/// contend on the lock (which the manager_race_test_hook is still holding). +/// This replaces the `std::thread::sleep` timing window — the blocked/about-to- +/// contend state is OBSERVED, not assumed. [F7: bounded arrival coordination] +/// +/// Zero-cost in production. +#[cfg(test)] +pub(crate) mod lifecycle_cancel_entry_hook { + use std::collections::HashMap; + use std::sync::Mutex; + use std::sync::OnceLock; + + static HOOKS: OnceLock>>> = + OnceLock::new(); + + fn hook_map() -> &'static Mutex>> { + HOOKS.get_or_init(|| Mutex::new(HashMap::new())) + } + + /// Arm a one-shot signal for `key`. Returns the receiver the test waits on. + pub(crate) fn arm(key: uuid::Uuid) -> std::sync::mpsc::Receiver<()> { + let (tx, rx) = std::sync::mpsc::channel(); + hook_map().lock().unwrap().insert(key, tx); + rx + } + + /// Disarm the hook for `key` (cleanup after test). + #[allow(dead_code)] + pub(crate) fn disarm(key: uuid::Uuid) { + hook_map().lock().unwrap().remove(&key); + } + + /// Called at the entry of `lifecycle_cancel`. + /// No-op when not armed for this key. + pub(crate) fn fire(key: uuid::Uuid) { + let tx = hook_map().lock().unwrap().remove(&key); + if let Some(t) = tx { + let _ = t.send(()); + } + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -1436,18 +2303,22 @@ pub(crate) mod tests { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(buffer_size); let (ctrl_tx, ctrl_rx) = mpsc::channel(buffer_size); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); + let community_control = CommunityConnectionControl::new(cancel.clone()); mgr.register( conn_id, tx, ctrl_tx, + terminal_ctrl_tx, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), 3, + community_control, ); (mgr, conn_id, rx, ctrl_rx, cancel, bp) } @@ -1456,9 +2327,13 @@ pub(crate) mod tests { /// checks resolve to `AdmissionError::Unavailable` without any live /// infrastructure. Shared with `crate::rejection`'s tests. pub(crate) async fn test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test_with_db_from_env: NIP-FI env parsing is isolated + // (never races nip_fi_config tests), but database_url is taken from + // DATABASE_URL / BUZZ_TEST_DATABASE_URL so postgres_tests that call + // `sqlx::PgPool::connect(&state.config.database_url)` reach a real DB + // in CI. Falls back to port-1 stub in unit-test runs. [F6] + let mut config = crate::config::Config::hermetic_for_test_with_db_from_env(); config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); build_test_state(config, pool).await } @@ -1467,7 +2342,9 @@ pub(crate) mod tests { /// tests deterministically exercise fail-closed database seams without /// depending on whether a developer has the normal test database running. pub(crate) async fn test_state_with_database_url(database_url: &str) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test: env-free — never races NIP-FI env-var mutations + // from concurrent nip_fi_config tests in the same binary. [F6] + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.database_url = database_url.to_owned(); @@ -1483,7 +2360,9 @@ pub(crate) mod tests { /// lifecycle tests use this to hold the sole connection as a deterministic /// barrier while AUTH waits in the real database acquisition path. pub(crate) async fn test_state_with_database_pool(pool: sqlx::PgPool) -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); + // hermetic_for_test: env-free — never races NIP-FI env-var mutations + // from concurrent nip_fi_config tests in the same binary. [F6] + let mut config = crate::config::Config::hermetic_for_test(); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config.read_database_url = None; @@ -1709,6 +2588,7 @@ pub(crate) mod tests { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); @@ -1723,9 +2603,14 @@ pub(crate) mod tests { subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), ctrl_tx, + terminal_ctrl_tx, cancel: cancel.clone(), backpressure_count: Arc::clone(&bp), grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: CommunityConnectionControl::new(cancel.clone()), }; let mgr = ConnectionManager::new(); @@ -1733,12 +2618,14 @@ pub(crate) mod tests { conn_id, tx, conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::clone(&conn.subscriptions), 3, + conn.community_control.clone(), ); // Fill the buffer via direct send. @@ -1780,23 +2667,27 @@ pub(crate) mod tests { conn_a, tx_a, ctrl_tx_a, + mpsc::channel(1).0, None, CancellationToken::new(), community_a, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(CancellationToken::new()), ); mgr.register( conn_b, tx_b, ctrl_tx_b, + mpsc::channel(1).0, None, CancellationToken::new(), community_b, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(CancellationToken::new()), ); let pubkey = vec![7u8; 32]; @@ -1828,12 +2719,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, subscriptions, 3, + CommunityConnectionControl::new(CancellationToken::new()), ); assert_eq!(mgr.pubkey_for_conn(conn_id), None); @@ -2162,12 +3055,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); cancel @@ -2191,46 +3086,144 @@ pub(crate) mod tests { ); } - #[tokio::test] - async fn drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling() { - let mgr = Arc::new(ConnectionManager::new()); + // ── F10: ConnectionManager::disconnect_nip_fi sets AuthorizationDenied ──── + // + // When the deny-API closes an active root-WS connection via + // `disconnect_nip_fi`, the `nip_fi_reason_tx` inside `CommunityConnectionControl` + // must be set to `AuthorizationDenied` before the cancellation fires. + // The send loop reads this reason via `disconnect_reason.borrow()` and + // emits a 1008 POLICY close frame instead of a bare Close(None). + // [FI-TRACE-CLOSE-CODE] + #[test] + fn conn_manager_disconnect_nip_fi_sets_authorization_denied_reason() { + let mgr = ConnectionManager::new(); let conn_id = Uuid::new_v4(); + let pubkey = vec![0xabu8; 32]; + let (tx, _rx) = mpsc::channel(8); let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); - let (restart_tx, mut restart_rx) = mpsc::channel(1); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + mgr.register( conn_id, tx, ctrl_tx, - Some(restart_tx), + terminal_ctrl_tx, + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + control, ); + mgr.set_authenticated_identity(conn_id, pubkey.clone(), Some("test-issuer".to_owned())); - let drain_mgr = Arc::clone(&mgr); - let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); - let restart = restart_rx.recv().await.expect("restart command delivered"); - assert!(!drain.is_finished(), "drain waits for the writer flush"); - restart.flushed.send(true).expect("acknowledge flush"); + let closed = mgr.disconnect_nip_fi("test-issuer", &pubkey); - assert_eq!(drain.await.expect("drain task"), 1); + assert_eq!(closed, 1, "one matching connection must be closed"); + assert!(cancel.is_cancelled(), "connection token must be cancelled"); + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "reason must be AuthorizationDenied so the send loop emits 1008 POLICY", + ); + // Winner-only enqueue: the denial frame is enqueued on terminal_ctrl_tx. + let frame = terminal_ctrl_rx + .try_recv() + .expect("denial frame must be enqueued"); + let WsMessage::Text(text) = frame else { + panic!("expected Text frame, got {:?}", frame); + }; assert!( - !cancel.is_cancelled(), - "successful flush does not use cancellation fallback" + text.contains("authorization denied"), + "denial frame must contain 'authorization denied'; got: {text}" ); } - #[tokio::test] - async fn drain_all_jittered_cancels_when_restart_channel_is_full_or_closed() { - for keep_receiver in [true, false] { - let mgr = ConnectionManager::new(); - let conn_id = Uuid::new_v4(); - let (tx, _rx) = mpsc::channel(8); - let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + #[test] + fn conn_manager_disconnect_nip_fi_ignores_unproven_connection() { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let pubkey = vec![0xabu8; 32]; + + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + mgr.register( + conn_id, + tx, + ctrl_tx, + mpsc::channel(1).0, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + control, + ); + // No set_authenticated_pubkey — simulates pre-NIP-42 state. + + let closed = mgr.disconnect_nip_fi("test-issuer", &pubkey); + + assert_eq!(closed, 0, "unproven connection must not be closed"); + assert!(!cancel.is_cancelled(), "unproven connection must stay live"); + assert_eq!( + *reason_rx.borrow(), + None, + "reason must remain None for untouched connection", + ); + } + + #[tokio::test] + async fn drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling() { + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, mut restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + mpsc::channel(1).0, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + CommunityConnectionControl::new(CancellationToken::new()), + ); + + let drain_mgr = Arc::clone(&mgr); + let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); + let restart = restart_rx.recv().await.expect("restart command delivered"); + assert!(!drain.is_finished(), "drain waits for the writer flush"); + restart.flushed.send(true).expect("acknowledge flush"); + + assert_eq!(drain.await.expect("drain task"), 1); + assert!( + !cancel.is_cancelled(), + "successful flush does not use cancellation fallback" + ); + } + + #[tokio::test] + async fn drain_all_jittered_cancels_when_restart_channel_is_full_or_closed() { + for keep_receiver in [true, false] { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); let (restart_tx, restart_rx) = mpsc::channel(1); let (pending_tx, _pending_rx) = tokio::sync::oneshot::channel(); if keep_receiver { @@ -2247,12 +3240,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, Some(restart_tx), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); assert_eq!(mgr.drain_all_jittered(1).await, 1); @@ -2278,12 +3273,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, Some(restart_tx), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); let drain_mgr = Arc::clone(&mgr); @@ -2318,12 +3315,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); (ctrl_rx, cancel) }; @@ -2370,12 +3369,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx.clone(), + mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); // Wedge the 1-slot control channel. ctrl_tx @@ -2418,12 +3419,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); assert!( @@ -2456,12 +3459,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); let closed = mgr.drain_all(); @@ -2493,12 +3498,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(cancel.clone()), ); let jitter_ms = 20_000u64; @@ -2531,12 +3538,14 @@ pub(crate) mod tests { late_id, late_tx, late_ctrl_tx, + mpsc::channel(1).0, None, late_cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + CommunityConnectionControl::new(late_cancel.clone()), ); assert!( late_cancel.is_cancelled(), @@ -2571,4 +3580,1978 @@ pub(crate) mod tests { other => panic!("expected a restart close frame, got {other:?}"), } } + + // ── F9: NIP-FI targeted disconnect also closes huddle audio sockets ─────── + + #[test] + fn nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xca)); + let target_pubkey = vec![0x42u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); + assert!(cancel.is_cancelled(), "audio socket must be cancelled"); + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "close reason must be AuthorizationDenied so send_loop sends 1008" + ); + } + + #[test] + fn nip_fi_disconnect_does_not_close_unproven_audio_socket() { + // A socket that registered but has not yet completed NIP-42 auth (no + // proven pubkey) must not be touched by a targeted disconnect. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcb)); + let target_pubkey = vec![0x42u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Intentionally skip set_proven_pubkey — simulates pre-auth state. + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 0); + assert!( + !cancel.is_cancelled(), + "pre-auth socket must not be touched" + ); + } + + // ── Issuer scope: a deny entry keyed (A, K) closes only sessions admitted + // under A. A same-key session admitted under B (or with no assertion) is + // untouched — no cancel, no frame. [FI-TRACE-DENY-SET] + #[test] + fn conn_manager_disconnect_nip_fi_is_issuer_scoped() { + let mgr = ConnectionManager::new(); + let key = vec![0xabu8; 32]; + let register = |issuer: Option<&str>| { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + terminal_ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + CommunityConnectionControl::new(cancel.clone()), + ); + mgr.set_authenticated_identity(conn_id, key.clone(), issuer.map(str::to_owned)); + (cancel, terminal_ctrl_rx) + }; + let (cancel_a, mut frames_a) = register(Some("https://issuer-a.example")); + let (cancel_b, mut frames_b) = register(Some("https://issuer-b.example")); + let (cancel_none, mut frames_none) = register(None); + + assert_eq!(mgr.disconnect_nip_fi("https://issuer-a.example", &key), 1); + + assert!(cancel_a.is_cancelled(), "A session must be closed"); + assert!( + frames_a.try_recv().is_ok(), + "A session must receive the denial frame" + ); + assert!(!cancel_b.is_cancelled(), "B session must survive an A deny"); + assert!( + frames_b.try_recv().is_err(), + "B session must not get a frame" + ); + assert!( + !cancel_none.is_cancelled(), + "no-assertion session must survive" + ); + assert!( + frames_none.try_recv().is_err(), + "no-assertion session must not get a frame" + ); + } + + #[test] + fn nip_fi_disconnect_audio_is_issuer_scoped() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xce)); + let key = vec![0x42u8; 32]; + let register = |issuer: Option<&str>| { + let cancel = CancellationToken::new(); + let (terminal_tx, terminal_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(key.clone(), issuer.map(str::to_owned)); + control.set_terminal_frame_sender(terminal_tx); + let guard = registry.register(Uuid::new_v4(), community, control); + (cancel, terminal_rx, guard) + }; + let (cancel_a, mut frames_a, _ga) = register(Some("https://issuer-a.example")); + let (cancel_b, mut frames_b, _gb) = register(Some("https://issuer-b.example")); + let (cancel_none, mut frames_none, _gn) = register(None); + + assert_eq!( + registry.disconnect_nip_fi("https://issuer-a.example", &key), + 1 + ); + + assert!(cancel_a.is_cancelled(), "A audio session must be closed"); + assert!( + frames_a.try_recv().is_ok(), + "A audio session must receive the denial frame" + ); + assert!( + !cancel_b.is_cancelled(), + "B audio session must survive an A deny" + ); + assert!( + frames_b.try_recv().is_err(), + "B audio session must not get a frame" + ); + assert!( + !cancel_none.is_cancelled(), + "no-assertion audio session must survive" + ); + assert!( + frames_none.try_recv().is_err(), + "no-assertion audio session must not get a frame" + ); + } + + #[test] + fn nip_fi_disconnect_does_not_close_different_pubkey_audio_socket() { + // A socket whose proven pubkey is different from the target must not + // be closed — the scan must be key-exact. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcc)); + let target_pubkey = vec![0x42u8; 32]; + let other_pubkey = vec![0x99u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(other_pubkey, Some("test-issuer".to_owned())); + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 0); + assert!( + !cancel.is_cancelled(), + "different-key socket must not be touched" + ); + } + + #[test] + fn nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer() { + // Two audio sockets in the same community: only the target's is closed. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcd)); + let target_pubkey = vec![0x42u8; 32]; + let peer_pubkey = vec![0x55u8; 32]; + + let target_cancel = CancellationToken::new(); + let target_control = CommunityConnectionControl::new(target_cancel.clone()); + target_control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + let _target_guard = registry.register(Uuid::new_v4(), community, target_control); + + let peer_cancel = CancellationToken::new(); + let peer_control = CommunityConnectionControl::new(peer_cancel.clone()); + peer_control.set_proven_identity(peer_pubkey, Some("test-issuer".to_owned())); + let _peer_guard = registry.register(Uuid::new_v4(), community, peer_control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); + assert!( + target_cancel.is_cancelled(), + "target audio socket must be cancelled" + ); + assert!( + !peer_cancel.is_cancelled(), + "collocated peer must remain connected" + ); + } + + // ── Fix-2: first-terminal-writer-wins reason publication ────────────────── + // + // `publish_disconnect_reason` must be atomic-first-writer-wins: the second + // concurrent cause must NOT overwrite the first. + // + // Two sequential precedence tests cover the aliasing defect Thufir found: + // A) Reverse the call order → both would pass with the old `send_replace` + // because neither ever reads `Some` before writing — but the wrong + // reason is published, so only one direction would match the asserted + // value, making the test suite catch the regression. + // B) Replace `send_if_modified` with `send_replace` → both tests fail + // because the second writer always overwrites the first. + // C) Supply `Some(_)` guard but wrong variant → specific `assert_eq` fails. + + #[test] + fn community_disconnect_then_nip_fi_keeps_community_deleted_reason() { + // CommunityDeleted fires first, AuthorizationDenied arrives second. + // The slot must retain CommunityDeleted. + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + // First writer: CommunityDeleted (via disconnect_community). + control.disconnect_community(); + // Second writer: AuthorizationDenied — must be ignored (via disconnect_nip_fi). + control.disconnect_nip_fi(); + + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted (first writer) must not be clobbered by AuthorizationDenied" + ); + } + + #[test] + fn nip_fi_disconnect_then_community_keeps_authorization_denied_reason() { + // AuthorizationDenied fires first, CommunityDeleted arrives second. + // The slot must retain AuthorizationDenied. + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + // First writer: AuthorizationDenied (via disconnect_nip_fi). + control.disconnect_nip_fi(); + // Second writer: CommunityDeleted — must be ignored (via disconnect_community). + control.disconnect_community(); + + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied (first writer) must not be clobbered by CommunityDeleted" + ); + } + + // ── Fix-2 payload-coupling tests ────────────────────────────────────────── + // + // These two tests prove that `disconnect_nip_fi` enqueues the denial payload + // ONLY when it wins reason publication — never when another cause already + // holds the reason slot. + // + // Mutation evidence: + // A) Remove the `won` gate and always `try_send` unconditionally (revert to + // pass-1 behavior) → the losing-deny test's `is_err()` assertion fails + // because a frame IS queued against the CommunityDeleted close. + // B) Remove the `send_if_modified` call inside the lock (make it always + // return true) → same outcome as (A) in the delete-then-deny case. + // C) Move `send_if_modified` outside the lock → the atomicity gap reopens; + // a concurrent community deletion that wins reason between the outer + // `send_if_modified` check and the inner `try_send` would still enqueue + // a denial frame against the wrong close reason (race, not directly + // tested here but the lock is the structural fix). + + #[test] + fn disconnect_nip_fi_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // disconnect_nip_fi fires first → wins reason → enqueues denial frame. + // disconnect_community fires second → loses reason → no second frame queued. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_terminal_frame_sender(terminal_tx); + + // First writer: disconnect_nip_fi (Authorization wins reason slot). + control.disconnect_nip_fi(); + // Second writer: disconnect_community (CommunityDeleted loses — slot already set). + control.disconnect_community(); + + // Reason slot retains AuthorizationDenied. + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when nip_fi wins reason" + ); + + // Exactly one frame queued — the winning denial payload. + let frame = terminal_rx + .try_recv() + .expect("winning disconnect_nip_fi must enqueue a denial frame"); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + assert_eq!( + frame, expected, + "queued frame must be the canonical Audio denial frame" + ); + // No second frame — losing delete must not queue anything. + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_community must not enqueue a second frame" + ); + } + + #[test] + fn disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame() { + // disconnect_community fires first → wins reason → no payload (community-deleted + // path is intentionally payload-less). + // disconnect_nip_fi fires second → loses reason → must NOT enqueue a denial + // frame against the CommunityDeleted close. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_terminal_frame_sender(terminal_tx); + + // First writer: disconnect_community. + control.disconnect_community(); + // Second writer: disconnect_nip_fi — loses reason slot. + control.disconnect_nip_fi(); + + // Reason slot retains CommunityDeleted. + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + + // No frame queued — losing deny must not send an authorization_denied payload + // against a community-deleted close. + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_nip_fi must not enqueue a denial frame when community wins reason" + ); + } + + // ── W_cancel_race: concurrent deny-win + community-delete cancel ordering ── + // + // Witnesses that a losing disconnect_community's cancel.cancel() cannot fire + // before the winning disconnect_nip_fi's try_send completes. + // + // A consumer thread wakes on cancel and immediately drains the terminal channel. + // With the fix, community's cancel is blocked until deny has enqueued the payload; + // the consumer always sees the frame. Without the fix (mutation), community's + // cancel fires while deny is paused between reason-win and try_send; the consumer + // wakes on an empty channel — close-only, no payload. + // + // Setup: + // - Arm cancel_race_test_hook: pauses deny after winning reason (inside lock). + // - Spawn a consumer thread: waits for cancel, then immediately try_recv. + // - Spawn a deny thread. + // - Main thread: barrier-rendezvous (deny has won reason + lock held), then + // call disconnect_community (blocks on lock in fixed form; runs cancel + // immediately in mutation form). + // - Hook sleep expires → deny completes try_send, drops lock, cancels. + // - Consumer wakes on cancel, drains channel. + // - Join all threads, check consumer result. + // + // Mutation evidence (executed, not tabled): + // - Revert disconnect_community to unserialized form → consumer wakes on + // community's premature cancel → try_recv returns Err → RED. + // - Restore exact head → PASS. + #[test] + fn w_cancel_race_deny_payload_precedes_community_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_terminal_frame_sender(terminal_tx); + + // Bounded rendezvous: hook signals "reached critical section" on + // hook_ready_tx; main thread receives on hook_ready_rx (5 s timeout). + // Main thread signals "proceed" on hook_proceed_tx; hook receives on + // hook_proceed_rx (5 s timeout). Replaces the Barrier::new(2) + + // sleep(30ms) pattern — bounded, fails fast with a diagnostic rather + // than hanging indefinitely. [F7: bounded rendezvous] + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + let hook_key = control.hook_key; + + // Arm: fires after reason win, while terminal_frame_tx lock is held. + cancel_race_test_hook::arm( + hook_key, + Arc::new(move || { + // Signal main thread: deny has won reason and the lock is held. + hook_ready_tx.send(()).unwrap(); + // Wait for main thread's permission to proceed (lock is held + // here — disconnect_community blocks on it in the fixed code). + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_cancel_race hook: main thread did not send proceed within 5 s — \ + disconnect_community probably never called or deadlocked", + ); + }), + ); + + // Consumer thread: wakes on the FIRST cancel signal and immediately + // drains the terminal channel. With the fix, the first cancel fires + // only after deny's try_send. With the mutation, community's cancel + // fires before try_send, and the consumer sees an empty channel. + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + // Block until the first cancel fires. Bounded by a 10-second deadline + // so a broken cancel path fails fast rather than hanging the test suite. + // [F7: bounded worker lifetime] + let stop = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !cancel_for_consumer.is_cancelled() { + if std::time::Instant::now() >= stop { + panic!( + "W_cancel_race: consumer did not observe cancel within 10 s — \ + cancel was never fired (broken cancellation path)" + ); + } + std::thread::yield_now(); + } + // Drain immediately. + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + }); + + let control_for_deny = control.clone(); + let deny_thread = std::thread::spawn(move || { + control_for_deny.disconnect_nip_fi(); + }); + + // Wait for deny to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_cancel_race: hook never fired within 5 s — \ + mutation: disconnect_nip_fi hook path never reached", + ); + + // F7 fix: spawn disconnect_community on an independent worker so the main + // thread remains free to send `proceed` to the hook. disconnect_community + // blocks on the transition lock (held by deny) — calling it directly on main + // while the hook holds the lock waiting for main's proceed creates a circular + // wait. + // + // FIXED: disconnect_community acquires the lock — blocks until deny drops it + // after try_send, so community's cancel fires after the payload is enqueued. + // MUTATION: disconnect_community without the lock — cancel fires before try_send. + let control_for_dc = control.clone(); + let dc_thread = std::thread::spawn(move || { + control_for_dc.disconnect_community(); + }); + + // Allow the hook to proceed (deny can now complete try_send, then drop the + // lock so disconnect_community can acquire it). + hook_proceed_tx.send(()).unwrap(); + + dc_thread + .join() + .expect("W_cancel_race: disconnect_community thread panicked"); + deny_thread + .join() + .expect("W_cancel_race: deny thread panicked"); + consumer_thread + .join() + .expect("W_cancel_race: consumer thread panicked"); + cancel_race_test_hook::disarm(hook_key); + + // Consumer observed the channel at the moment of the first cancel. + // With the fix: deny's try_send already happened → frame present. + // With the mutation: community's premature cancel → channel empty. + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_cancel_race: consumer must observe the denial payload at the first cancel signal \ + (proves cancel cannot fire before try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + assert_eq!( + frame, expected, + "W_cancel_race: queued frame must be the canonical Audio denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_cancel_race: cancel must be set after both disconnect calls" + ); + } + + // ── Expiry/delete ordered and race witnesses ───────────────────────────────────────────── + // + // These tests cover the expiry-task path: + // 1. Sequential: expiry wins reason → frame enqueued; losing delete doesn't. + // 2. Sequential: delete wins reason → no payload; losing expiry doesn't enqueue. + // 3. Concurrent (W_expiry_cancel_race): expiry wins reason, is paused before + // try_send while the lock is held; concurrent disconnect_community must + // block and NOT fire cancel until expiry's try_send completes. + // + // Mutation evidence for the concurrent test: + // - Remove the lock acquisition from disconnect_community → community's + // cancel fires before expiry's try_send → consumer wakes on empty channel + // → RED. Restore → PASS. + + #[test] + fn expiry_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // expiry_deny_terminal fires first → wins AuthorizationDenied. + // disconnect_community fires second → loses, queues nothing. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + // First writer: expiry path. + control.expiry_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Audio); + // Second writer: community delete — must lose. + control.disconnect_community(); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when expiry wins reason" + ); + let frame = terminal_rx + .try_recv() + .expect("expiry_deny_terminal must enqueue a denial frame when it wins"); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + assert_eq!( + frame, expected, + "enqueued frame must be the Audio denial frame" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_community must not enqueue a second frame" + ); + } + + #[test] + fn delete_wins_reason_losing_expiry_does_not_enqueue_frame() { + // disconnect_community fires first → wins CommunityDeleted (payload-less). + // expiry_deny_terminal fires second → loses, must NOT enqueue a denial + // frame against the CommunityDeleted close. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + // First writer: community delete wins reason. + control.disconnect_community(); + // Second writer: expiry path loses. + control.expiry_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Audio); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing expiry_deny_terminal must not enqueue a denial frame when community wins" + ); + } + + // ── W_expiry_cancel_race: expiry wins reason, concurrent delete cannot cancel + // before the winning enqueue. ────────────────────────────────────────────── + // + // Shape mirrors W_cancel_race but for the expiry path. Hook fires inside + // expiry_deny_terminal after reason-win, while the lock is held. Main thread + // calls disconnect_community, which in the fixed code blocks on the lock and + // cannot call cancel.cancel() until expiry's try_send completes. + #[test] + fn w_expiry_cancel_race_payload_precedes_community_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + let hook_key = control.hook_key; + + // Arm: fires after expiry wins reason, while the lock is held. + expiry_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_expiry_cancel_race hook: main thread did not send proceed within 5 s", + ); + }), + ); + + // Consumer: wakes on first cancel, drains terminal channel immediately. + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + while !cancel_for_consumer.is_cancelled() { + std::thread::yield_now(); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + }); + + // Expiry thread: calls expiry_deny_terminal then cancels. + let control_for_expiry = control.clone(); + let cancel_for_expiry = cancel.clone(); + let terminal_tx_for_expiry = terminal_tx; + let expiry_thread = std::thread::spawn(move || { + control_for_expiry.expiry_deny_terminal( + &terminal_tx_for_expiry, + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + // Cancel here (gate.expire() does this in production after the + // terminal closure returns). + cancel_for_expiry.cancel(); + }); + + // Wait for expiry to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_expiry_cancel_race: hook never fired within 5 s — \ + mutation: expiry_deny_terminal hook path never reached", + ); + + // F7 fix: spawn disconnect_community on an independent worker so the main + // thread remains free to send `proceed` to the hook. disconnect_community + // blocks on the transition lock (held by expiry) — calling it directly on main + // while the hook holds the lock waiting for main's proceed creates a circular + // wait. + // + // FIXED: disconnect_community acquires the lock — blocks until expiry drops it + // after try_send, so community's cancel fires after the payload is enqueued. + // MUTATION (remove lock from disconnect_community): cancel fires before try_send. + let control_for_dc = control.clone(); + let dc_thread = std::thread::spawn(move || { + control_for_dc.disconnect_community(); + }); + + // Allow the hook to proceed (expiry can now complete try_send, then drop the + // lock so disconnect_community can acquire it). + hook_proceed_tx.send(()).unwrap(); + + dc_thread + .join() + .expect("W_expiry_cancel_race: disconnect_community thread panicked"); + expiry_thread + .join() + .expect("W_expiry_cancel_race: expiry thread panicked"); + consumer_thread + .join() + .expect("W_expiry_cancel_race: consumer thread panicked"); + expiry_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_expiry_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_expiry_cancel_race: consumer must observe the denial payload at the first cancel \ + signal (proves expiry cancel cannot fire before try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + assert_eq!( + frame, expected, + "W_expiry_cancel_race: frame must be the canonical Audio denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_expiry_cancel_race: cancel must be set after both calls" + ); + } + + // ── Root key-pairing ordered and race witnesses ─────────────────────────── + // + // These tests cover the root key-pairing path (`pairing_deny_terminal`): + // 1. Sequential: pairing wins reason → frame enqueued; losing delete doesn't. + // 2. Sequential: delete wins reason → no payload; losing pairing doesn't enqueue. + // 3. Concurrent (W_pairing_cancel_race): pairing wins reason, is paused before + // try_send while the lock is held; concurrent disconnect_community must + // block and NOT fire cancel until pairing's try_send completes. + // + // Mutation evidence for the concurrent test: + // - Remove the lock acquisition from disconnect_community → community's + // cancel fires before pairing's try_send → consumer wakes on empty channel + // → RED. Restore → PASS. + + #[test] + fn pairing_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // pairing_deny_terminal fires first → wins AuthorizationDenied. + // disconnect_community fires second → loses, queues nothing. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + // First writer: root pairing path. + control.pairing_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Root); + // Second writer: community delete — must lose. + control.disconnect_community(); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when pairing wins reason" + ); + let frame = terminal_rx + .try_recv() + .expect("pairing_deny_terminal must enqueue a denial frame when it wins"); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "enqueued frame must be the Root denial frame" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_community must not enqueue a second frame" + ); + } + + #[test] + fn delete_wins_reason_losing_pairing_does_not_enqueue_frame() { + // disconnect_community fires first → wins CommunityDeleted (payload-less). + // pairing_deny_terminal fires second → loses, must NOT enqueue a denial + // frame against the CommunityDeleted close. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + // First writer: community delete wins reason. + control.disconnect_community(); + // Second writer: pairing path loses. + control.pairing_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Root); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing pairing_deny_terminal must not enqueue a denial frame when community wins" + ); + } + + // ── W_pairing_cancel_race: root pairing wins reason, concurrent delete cannot + // cancel before the winning enqueue. ───────────────────────────────────── + // + // Shape mirrors W_cancel_race / W_expiry_cancel_race but for the root pairing + // path. Hook fires inside pairing_deny_terminal after reason-win, while the + // lock is held. Main thread calls disconnect_community, which in the fixed + // code blocks on the lock and cannot call cancel.cancel() until pairing's + // try_send completes. + // + // Mutation evidence (executed, not tabled): + // - Revert disconnect_community to unserialized form (remove lock acquisition) + // → community fires cancel before pairing's try_send → consumer wakes on + // empty channel → RED. + // - Restore exact head → PASS. + #[test] + fn w_pairing_cancel_race_payload_precedes_community_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + let hook_key = control.hook_key; + + // Arm: fires after pairing wins reason, while the lock is held. + pairing_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_pairing_cancel_race hook: main thread did not send proceed within 5 s", + ); + }), + ); + + // Consumer: wakes on first cancel, drains terminal channel immediately. + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + while !cancel_for_consumer.is_cancelled() { + std::thread::yield_now(); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + }); + + // Pairing thread: calls pairing_deny_terminal then cancels. + let control_for_pairing = control.clone(); + let cancel_for_pairing = cancel.clone(); + let terminal_tx_for_pairing = terminal_tx; + let pairing_thread = std::thread::spawn(move || { + control_for_pairing.pairing_deny_terminal( + &terminal_tx_for_pairing, + crate::nip_fi_session::NipFiWsRoute::Root, + ); + // Cancel here (in production, conn.cancel.cancel() follows immediately + // after pairing_deny_terminal returns in enforce_nip_fi_key_pairing). + cancel_for_pairing.cancel(); + }); + + // Wait for pairing to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_pairing_cancel_race: hook never fired within 5 s — \ + mutation: pairing_deny_terminal hook path never reached", + ); + + // F7 fix: spawn disconnect_community on an independent worker so the main + // thread remains free to send `proceed` to the hook. disconnect_community + // blocks on the transition lock (held by pairing) — calling it directly on main + // while the hook holds the lock waiting for main's proceed creates a circular + // wait. + // + // FIXED: disconnect_community acquires the lock — blocks until pairing drops it + // after try_send, so community's cancel fires after the payload is enqueued. + // MUTATION (remove lock from disconnect_community): cancel fires before try_send. + let control_for_dc = control.clone(); + let dc_thread = std::thread::spawn(move || { + control_for_dc.disconnect_community(); + }); + + // Allow the hook to proceed (pairing can now complete try_send, then drop the + // lock so disconnect_community can acquire it). + hook_proceed_tx.send(()).unwrap(); + + dc_thread + .join() + .expect("W_pairing_cancel_race: disconnect_community thread panicked"); + pairing_thread + .join() + .expect("W_pairing_cancel_race: pairing thread panicked"); + consumer_thread + .join() + .expect("W_pairing_cancel_race: consumer thread panicked"); + pairing_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_pairing_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_pairing_cancel_race: consumer must observe the denial payload at the first cancel \ + signal (proves cancel cannot fire before try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "W_pairing_cancel_race: queued frame must be the canonical Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_pairing_cancel_race: cancel must be set after both calls" + ); + } + + // ── Auth-deny ordered tests ──────────────────────────────────────────────── + + #[test] + fn auth_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // auth_deny_terminal fires first → wins AuthorizationDenied. + // disconnect_community fires second → loses, queues nothing. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + control.auth_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Root); + control.disconnect_community(); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when auth wins reason" + ); + let frame = terminal_rx + .try_recv() + .expect("auth_deny_terminal must enqueue a denial frame when it wins"); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "enqueued frame must be the Root denial frame" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_community must not enqueue a second frame" + ); + } + + #[test] + fn delete_wins_reason_losing_auth_does_not_enqueue_frame() { + // disconnect_community fires first → wins CommunityDeleted (payload-less). + // auth_deny_terminal fires second → loses, must NOT enqueue a denial + // frame against the CommunityDeleted close. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + control.disconnect_community(); + control.auth_deny_terminal(&terminal_tx, crate::nip_fi_session::NipFiWsRoute::Root); + + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing auth_deny_terminal must not enqueue a denial frame when community wins" + ); + } + + // ── W_auth_cancel_race: auth handler wins reason, concurrent delete cannot + // cancel before the winning enqueue. ───────────────────────────────────── + // + // Mirrors W_pairing_cancel_race but for the post-registration deny-set path. + // Hook fires inside auth_deny_terminal after reason-win, while the lock is held. + // + // Mutation evidence: revert disconnect_community to unserialized form → + // community fires cancel before auth's try_send → consumer wakes on empty + // channel → RED. Restore → PASS. + #[test] + fn w_auth_cancel_race_payload_precedes_community_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + let hook_key = control.hook_key; + + auth_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_auth_cancel_race hook: main thread did not send proceed within 5 s"); + }), + ); + + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + while !cancel_for_consumer.is_cancelled() { + std::thread::yield_now(); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + }); + + let control_for_auth = control.clone(); + let cancel_for_auth = cancel.clone(); + let terminal_tx_for_auth = terminal_tx; + let auth_thread = std::thread::spawn(move || { + control_for_auth.auth_deny_terminal( + &terminal_tx_for_auth, + crate::nip_fi_session::NipFiWsRoute::Root, + ); + cancel_for_auth.cancel(); + }); + + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_auth_cancel_race: hook never fired within 5 s — \ + mutation: auth_deny_terminal hook path never reached", + ); + + // F7 fix: spawn disconnect_community on an independent worker so the main + // thread remains free to send `proceed` to the hook. disconnect_community + // blocks on the transition lock (held by auth) — calling it directly on main + // while the hook holds the lock waiting for main's proceed creates a circular + // wait. + // + // FIXED: disconnect_community acquires the lock — blocks until auth drops it + // after try_send, so community's cancel fires after the payload is enqueued. + // MUTATION: disconnect_community without the lock — cancel fires before try_send. + let control_for_dc = control.clone(); + let dc_thread = std::thread::spawn(move || { + control_for_dc.disconnect_community(); + }); + + // Allow the hook to proceed (auth can now complete try_send, then drop the + // lock so disconnect_community can acquire it). + hook_proceed_tx.send(()).unwrap(); + + dc_thread + .join() + .expect("W_auth_cancel_race: disconnect_community thread panicked"); + auth_thread + .join() + .expect("W_auth_cancel_race: auth thread panicked"); + consumer_thread + .join() + .expect("W_auth_cancel_race: consumer thread panicked"); + + auth_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_auth_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_auth_cancel_race: consumer must observe the denial payload at the first cancel \ + signal (proves cancel cannot fire before try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "W_auth_cancel_race: queued frame must be the canonical Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_auth_cancel_race: cancel must be set after both calls" + ); + } + + // ── Manager-disconnect ordered tests ────────────────────────────────────── + + #[test] + fn manager_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // manager_disconnect_nip_fi fires first → wins AuthorizationDenied. + // disconnect_community fires second → loses, queues nothing. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + control.manager_disconnect_nip_fi(&terminal_tx); + // disconnect_community is a no-op on reason (slot already set). + // Cannot call it here because manager_disconnect_nip_fi already cancelled; + // test the frame delivery instead. + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when manager wins reason" + ); + let frame = terminal_rx + .try_recv() + .expect("manager_disconnect_nip_fi must enqueue a denial frame when it wins"); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "enqueued frame must be the Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "manager_disconnect_nip_fi must cancel the token" + ); + } + + #[test] + fn delete_wins_reason_losing_manager_does_not_enqueue_frame() { + // disconnect_community fires first → wins CommunityDeleted. + // manager_disconnect_nip_fi fires second → loses reason, must NOT enqueue + // a denial frame against the CommunityDeleted close. + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Use a control for community delete that doesn't cancel manager's token. + let delete_control = CommunityConnectionControl::new(CancellationToken::new()); + // Share the same reason_tx between the two controls by setting reason directly. + // Simulate delete winning by calling disconnect_community on a fresh control + // whose reason_tx is the same (they share via Arc). Here we simply call both + // in order on a single shared control. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let shared_cancel = CancellationToken::new(); + let shared_control = CommunityConnectionControl::new(shared_cancel.clone()); + + // First: delete wins. + shared_control.disconnect_community(); + // Second: manager loses. + shared_control.manager_disconnect_nip_fi(&terminal_tx); + + assert_eq!( + *shared_control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + assert!( + terminal_rx.try_recv().is_err(), + "losing manager_disconnect_nip_fi must not enqueue a denial frame when community wins" + ); + // Both paths cancel the same token; it must be cancelled. + assert!(shared_cancel.is_cancelled()); + drop((control, delete_control)); + } + + // ── W_manager_cancel_race: manager wins reason, concurrent delete cannot + // cancel before the winning enqueue. ───────────────────────────────────── + // + // Mirrors W_pairing_cancel_race / W_auth_cancel_race but for the + // ConnectionManager close-scan path. Hook fires inside manager_disconnect_nip_fi + // after reason-win, while the lock is held. + // + // Mutation evidence: revert disconnect_community to unserialized form → + // community fires cancel before manager's try_send → consumer wakes on empty + // channel → RED. Restore → PASS. + #[test] + fn w_manager_cancel_race_payload_precedes_community_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Capture the per-control key before arm() so the hook is scoped to + // this control instance. Parallel tests arm different keys. [F7] + let hook_key = control.hook_key; + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + + manager_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_manager_cancel_race hook: main thread did not send proceed within 5 s", + ); + }), + ); + + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + while !cancel_for_consumer.is_cancelled() { + std::thread::yield_now(); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + }); + + let control_for_manager = control.clone(); + let terminal_tx_for_manager = terminal_tx; + let manager_thread = std::thread::spawn(move || { + control_for_manager.manager_disconnect_nip_fi(&terminal_tx_for_manager); + // manager_disconnect_nip_fi cancels internally; no separate cancel() needed. + }); + + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_manager_cancel_race: hook never fired within 5 s — \ + mutation: manager_disconnect_nip_fi hook path never reached", + ); + + // F7 fix: spawn disconnect_community on an independent worker so the main + // thread remains free to send `proceed` to the hook. disconnect_community + // blocks on the transition lock (held by manager) — calling it directly on main + // while the hook holds the lock waiting for main's proceed creates a circular + // wait. + // + // FIXED: disconnect_community acquires the lock — blocks until manager drops it + // after try_send, so community's cancel fires after the payload is enqueued. + // MUTATION: disconnect_community without the lock — cancel fires before try_send. + let control_for_dc = control.clone(); + let dc_thread = std::thread::spawn(move || { + control_for_dc.disconnect_community(); + }); + + // Allow the hook to proceed (manager can now complete try_send, then drop the + // lock so disconnect_community can acquire it). + hook_proceed_tx.send(()).unwrap(); + + dc_thread + .join() + .expect("W_manager_cancel_race: disconnect_community thread panicked"); + manager_thread + .join() + .expect("W_manager_cancel_race: manager thread panicked"); + consumer_thread + .join() + .expect("W_manager_cancel_race: consumer thread panicked"); + + manager_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_manager_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_manager_cancel_race: consumer must observe the denial payload at the first cancel \ + signal (proves cancel cannot fire before try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "W_manager_cancel_race: queued frame must be the canonical Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_manager_cancel_race: cancel must be set after both calls" + ); + } + + // ── lifecycle_cancel ordered and race witness ───────────────────────────── + + #[test] + fn lifecycle_cancel_does_not_enqueue_frame_but_cancels_token() { + // lifecycle_cancel must: (a) NOT enqueue any frame (payload-free path); + // (b) cancel the token so the send loop exits; + // (c) win the reason slot with LifecycleClosed (sentinel that blocks + // concurrent manager-disconnect from later enqueuing a denial frame). + // [F2: lifecycle-cancel serialized with terminal publication] + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel::(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let _ = terminal_tx; // keep alive — not relevant to this path + control.lifecycle_cancel(); + assert!( + cancel.is_cancelled(), + "lifecycle_cancel must cancel the token" + ); + assert!( + terminal_rx.try_recv().is_err(), + "lifecycle_cancel must not enqueue any terminal frame" + ); + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::LifecycleClosed), + "lifecycle_cancel must win the reason slot with LifecycleClosed sentinel" + ); + } + + // ── W_lifecycle_cancel_race_reverse: lifecycle wins reason first, concurrent + // manager-disconnect must not enqueue a frame after lifecycle fires cancel. + // + // This is the REVERSE ORDER witness for F2. The existing + // `w_lifecycle_cancel_race` witness proves: manager wins reason first (hook + // pauses it), lifecycle calls lifecycle_cancel() — lifecycle must block on the + // lock until manager's try_send finishes, so the consumer sees the frame. + // + // THIS witness proves: lifecycle wins the reason slot first, fires cancel (inside + // the lock), then drops the lock. A concurrent manager-disconnect acquires the + // lock after lifecycle drops it, finds the slot already won (LifecycleClosed), + // and MUST NOT try_send. The send loop — which has already been cancelled — + // must observe consistent terminal state (no orphaned frame written after drain). + // + // Concrete: call lifecycle_cancel() first (wins LifecycleClosed), then call + // manager_disconnect_nip_fi (loses, no try_send). Assert: (a) reason stays + // LifecycleClosed (manager did not overwrite), (b) terminal channel is empty + // (no frame enqueued by losing manager). + // + // Mutation evidence: remove the `if current.is_none()` guard in lifecycle_cancel + // so lifecycle no longer wins the slot → manager can still enqueue a frame after + // lifecycle fires cancel → terminal channel is non-empty → assertion panics. + // Restore → PASS. + #[test] + fn w_lifecycle_cancel_race_reverse_manager_loses_after_lifecycle_wins() { + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel::(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + // Lifecycle fires first — wins LifecycleClosed, cancels token. + control.lifecycle_cancel(); + assert!( + cancel.is_cancelled(), + "W_lifecycle_cancel_race_reverse: token must be cancelled after lifecycle_cancel" + ); + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::LifecycleClosed), + "W_lifecycle_cancel_race_reverse: lifecycle must win LifecycleClosed" + ); + + // Manager fires second — loses reason, must NOT enqueue. + control.manager_disconnect_nip_fi(&terminal_tx); + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::LifecycleClosed), + "W_lifecycle_cancel_race_reverse: manager must not overwrite LifecycleClosed reason" + ); + assert!( + terminal_rx.try_recv().is_err(), + "W_lifecycle_cancel_race_reverse: losing manager must not enqueue a denial frame \ + after lifecycle already fired cancel (prevents orphaned frame after send loop close)" + ); + } + + // ── W_lifecycle_cancel_race: lifecycle cancel cannot fire before the winning + // terminal enqueue — proves lifecycle_cancel acquires the transition lock. + // + // Pattern: arm manager_race_test_hook to pause manager_disconnect_nip_fi + // after reason-win while holding the lock. Main thread concurrently calls + // lifecycle_cancel() — under the fix it blocks on the lock; under mutation + // (lifecycle_cancel removed) it fires cancel immediately before the + // try_send runs, producing Empty at the consumer. + // + // Mutation evidence: revert lifecycle_cancel to bare cancel.cancel() → + // consumer wakes before manager's try_send → try_recv() returns Err(Empty) → RED. + // Restore → PASS. + #[test] + fn w_lifecycle_cancel_race_payload_precedes_lifecycle_cancel() { + use std::sync::Arc; + + let (terminal_tx, terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Capture per-control key for scoped hook. [F7] + let hook_key = control.hook_key; + + // Drop guard: disarms the hook on test exit regardless of panic or + // early return. Without this, a panic between arm() and the plain + // disarm() call below leaves the global hook slot occupied, which + // can cause interference in subsequent test runs. [F7: failure-path cleanup] + struct HookDisarm(uuid::Uuid); + impl Drop for HookDisarm { + fn drop(&mut self) { + manager_race_test_hook::disarm(self.0); + } + } + let _hook_guard = HookDisarm(hook_key); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + + manager_race_test_hook::arm( + hook_key, + Arc::new(move || { + // Signal main thread: manager has won reason, lock is held. + hook_ready_tx.send(()).unwrap(); + // Hold the lock — main's lifecycle_cancel must block here (fix) + // or fire cancel prematurely (mutation). + hook_proceed_rx + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_lifecycle_cancel_race hook: main thread did not send proceed within 5 s", + ); + }), + ); + + // Completion channels: each thread sends () when done so main can + // bound the join with recv_timeout rather than blocking indefinitely. + // This ensures a broken cancellation path or panicking thread produces + // a bounded test failure rather than a silent hang. [F7: bounded completion] + let (consumer_done_tx, consumer_done_rx) = std::sync::mpsc::channel::<()>(); + let (manager_done_tx, manager_done_rx) = std::sync::mpsc::channel::<()>(); + let (lc_done_tx, lc_done_rx) = std::sync::mpsc::channel::<()>(); + + // Consumer: waits for the first cancel signal (1ms sleep to avoid a + // CPU-hot busy-spin), then drains. Bounded by a 10-second deadline + // so a broken cancel path fails fast rather than hanging the test suite. + // [F7: bounded worker lifetime] + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + let stop = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !cancel_for_consumer.is_cancelled() { + if std::time::Instant::now() >= stop { + panic!( + "W_lifecycle_cancel_race: consumer did not observe cancel within 10 s — \ + broken cancellation path" + ); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + let _ = consumer_done_tx.send(()); + }); + + // Manager thread: wins reason, fires hook (pauses), try_send, drops lock, + // cancels. The hook pause creates the race window. + let control_for_manager = control.clone(); + let terminal_tx_for_manager = terminal_tx; + let manager_thread = std::thread::spawn(move || { + control_for_manager.manager_disconnect_nip_fi(&terminal_tx_for_manager); + let _ = manager_done_tx.send(()); + }); + + // Wait for manager to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_lifecycle_cancel_race: hook never fired within 5 s — \ + mutation: manager_disconnect_nip_fi hook path never reached", + ); + + // F7 fix: spawn lifecycle_cancel on an independent worker so the main + // thread remains free to send `proceed` to the hook. lifecycle_cancel + // blocks on the transition lock (held by manager) — this is what we are + // proving. Calling it directly on main while the hook holds the lock + // waiting for main's proceed creates a circular wait. + // + // Arm the entry hook BEFORE spawning so the receiver is ready before + // the thread can fire it. Drop guard: if the test panics before + // lc_thread fires the hook, the slot is cleaned up automatically. + // [F7: failure-path entry-hook cleanup] + let arrival_rx = lifecycle_cancel_entry_hook::arm(control.hook_key); + struct LcEntryHookGuard(uuid::Uuid); + impl Drop for LcEntryHookGuard { + fn drop(&mut self) { + lifecycle_cancel_entry_hook::disarm(self.0); + } + } + let _lc_entry_guard = LcEntryHookGuard(control.hook_key); + let control_for_lc = control.clone(); + let lc_thread = std::thread::spawn(move || { + // FIXED: lifecycle_cancel acquires the lock — blocks until manager + // drops it after try_send, so the consumer never sees an empty + // channel. + // MUTATION: lifecycle_cancel calls cancel.cancel() without the lock + // — consumer wakes before manager's try_send, sees Err(Empty). + control_for_lc.lifecycle_cancel(); + let _ = lc_done_tx.send(()); + }); + + // Wait for the lc_thread to enter lifecycle_cancel (observed via arrival + // hook, not assumed via sleep). At this point the thread has fired the + // hook and is about to acquire the lock, which the manager hook still + // holds. The blocked/contending state is established before we release. + // [F7: bounded arrival coordination] + arrival_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_lifecycle_cancel_race: lifecycle_cancel_entry_hook never fired — \ + lc_thread did not reach lifecycle_cancel within 5 s", + ); + + // Allow the hook to proceed (manager's try_send can now complete, + // then drops the lock so lifecycle_cancel can acquire it). + hook_proceed_tx.send(()).unwrap(); + + // Bounded completion: 5-second deadline per thread. A failure here means + // a thread is hung — the disarm below ensures the hook is cleared. + lc_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_lifecycle_cancel_race: lifecycle_cancel thread must complete within 5s [F7: bounded completion]"); + manager_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_lifecycle_cancel_race: manager thread must complete within 5s [F7: bounded completion]"); + consumer_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_lifecycle_cancel_race: consumer thread must complete within 5s [F7: bounded completion]"); + + // Join handles to surface panics; they have completed above so join is instant. + lc_thread + .join() + .expect("W_lifecycle_cancel_race: lifecycle_cancel thread panicked"); + manager_thread + .join() + .expect("W_lifecycle_cancel_race: manager thread panicked"); + consumer_thread + .join() + .expect("W_lifecycle_cancel_race: consumer thread panicked"); + + // Explicit disarm: _hook_guard's Drop impl also disarms but this + // keeps the intent visible here. Redundant, not harmful. + manager_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_lifecycle_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_lifecycle_cancel_race: consumer must observe the denial payload at the first \ + cancel signal (proves lifecycle_cancel cannot fire cancel before try_send)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "W_lifecycle_cancel_race: queued frame must be the canonical Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_lifecycle_cancel_race: cancel must be set after both calls" + ); + } + + // ── W_root_manager_drain_race: production-wiring witness ───────────────── + // + // Proves that a real `ConnectionManager::disconnect_nip_fi()` denial payload + // is visible to the consumer before `drain_all()`'s `lifecycle_cancel()` fires + // the cancellation token, using actual `ConnectionManager::register()` wiring. + // + // The current `w_lifecycle_cancel_race` calls the primitives directly on a + // bare control; this witness exercises the full production call path: + // manager.set_authenticated_pubkey → manager.disconnect_nip_fi() → [hook] + // → manager.drain_all() calls lifecycle_cancel() on the same entry. + // + // Setup: + // 1. Register one connection with a known pubkey via `ConnectionManager::register`. + // 2. Call `set_authenticated_pubkey` so `disconnect_nip_fi` matches it. + // 3. Arm `manager_race_test_hook`: after reason-win, rendezvous + hold lock. + // 4. Consumer thread: busy-wait for cancel, then drain terminal channel. + // 5. Deny thread: `manager.disconnect_nip_fi("test-issuer", &pubkey)`. + // 6. Main thread: rendezvous (deny holds lock), then `manager.drain_all()` + // (under the fix, blocks on the lock; under mutation, cancels immediately). + // 7. Verify consumer saw the denial payload. + // + // Mutation evidence (executed): + // Revert `lifecycle_cancel` to bare `cancel.cancel()` → + // `drain_all`'s cancel fires before `disconnect_nip_fi`'s `try_send` → + // consumer wakes on empty terminal channel → `try_recv()` returns `Err` → RED. + // Restore → PASS. + #[test] + fn w_root_manager_drain_race_payload_precedes_drain_lifecycle_cancel() { + use std::sync::Arc; + + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let pubkey = vec![0xdeu8; 32]; + + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Capture per-control key for scoped hook before moving control into register(). [F7] + let hook_key = control.hook_key; + + // Drop guard: disarms the hook on test exit regardless of panic or + // early return. [F7: failure-path cleanup] + struct HookDisarmMgr(uuid::Uuid); + impl Drop for HookDisarmMgr { + fn drop(&mut self) { + manager_race_test_hook::disarm(self.0); + } + } + let _hook_guard = HookDisarmMgr(hook_key); + + // Root connections use terminal_ctrl_tx passed to register(), not + // control.terminal_frame_tx (which is the audio-path slot). No + // set_terminal_frame_sender call needed here. + + mgr.register( + conn_id, + tx, + ctrl_tx, + terminal_ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + control, + ); + mgr.set_authenticated_identity(conn_id, pubkey.clone(), Some("test-issuer".to_owned())); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + + // Arm: fires after reason-win, while terminal_frame_tx lock is held by + // manager_disconnect_nip_fi. + manager_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx.lock().unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_root_manager_drain_race hook: main thread did not send proceed within 5 s"); + }), + ); + + let (consumer_done_tx, consumer_done_rx) = std::sync::mpsc::channel::<()>(); + let (deny_done_tx, deny_done_rx) = std::sync::mpsc::channel::<()>(); + let (drain_done_tx, drain_done_rx) = std::sync::mpsc::channel::<()>(); + + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_ctrl_rx; + let consumer_thread = std::thread::spawn(move || { + // Bounded by a 10-second deadline so a broken cancel path fails fast + // rather than hanging the test suite. [F7: bounded worker lifetime] + let stop = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !cancel_for_consumer.is_cancelled() { + if std::time::Instant::now() >= stop { + panic!( + "W_root_manager_drain_race: consumer did not observe cancel within 10 s — \ + broken cancellation path" + ); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + let _ = consumer_done_tx.send(()); + }); + + // Deny thread: real production path through ConnectionManager. + let mgr_for_deny = Arc::clone(&mgr); + let pubkey_for_deny = pubkey.clone(); + let deny_thread = std::thread::spawn(move || { + mgr_for_deny.disconnect_nip_fi("test-issuer", &pubkey_for_deny); + let _ = deny_done_tx.send(()); + }); + + // Wait for deny to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_root_manager_drain_race: hook never fired within 5 s — \ + mutation: manager_disconnect_nip_fi hook path never reached", + ); + + // F7 fix: spawn drain_all on an independent worker so the main thread + // remains free to send `proceed` to the hook. drain_all calls + // lifecycle_cancel which blocks on the transition lock (held by the deny + // thread) — this is what we are proving. Calling drain_all directly on + // main while the hook holds the lock waiting for main's proceed creates a + // circular wait. + // + // Arm the entry hook BEFORE spawning so the receiver is ready before + // the thread can fire it. Drop guard: if the test panics before + // drain_thread fires the hook, the slot is cleaned up. + // [F7: bounded arrival coordination; F7: failure-path entry-hook cleanup] + let arrival_rx = lifecycle_cancel_entry_hook::arm(hook_key); + struct LcEntryHookGuardDrain(uuid::Uuid); + impl Drop for LcEntryHookGuardDrain { + fn drop(&mut self) { + lifecycle_cancel_entry_hook::disarm(self.0); + } + } + let _lc_entry_guard = LcEntryHookGuardDrain(hook_key); + let mgr_for_drain = Arc::clone(&mgr); + let drain_thread = std::thread::spawn(move || { + // FIXED: lifecycle_cancel acquires the lock → blocks until deny's + // try_send completes → consumer always sees the frame. + // MUTATION: lifecycle_cancel calls cancel.cancel() bare → fires + // before deny's try_send → consumer wakes on empty terminal channel. + mgr_for_drain.drain_all(); + let _ = drain_done_tx.send(()); + }); + + // Wait for the drain_thread to enter lifecycle_cancel (observed via + // arrival hook, not assumed via sleep). At this point the thread has + // fired the hook and is about to acquire the lock, which the deny hook + // still holds. The blocked/contending state is established before we + // release. [F7: bounded arrival coordination] + arrival_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_root_manager_drain_race: lifecycle_cancel_entry_hook never fired — \ + drain_thread did not reach lifecycle_cancel within 5 s", + ); + + // Allow the hook to proceed (deny's try_send can now complete, then + // drops the lock so drain_all's lifecycle_cancel can acquire it). + hook_proceed_tx.send(()).unwrap(); + + drain_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_root_manager_drain_race: drain_all thread must complete within 5s [F7: bounded completion]"); + deny_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_root_manager_drain_race: deny thread must complete within 5s [F7: bounded completion]"); + consumer_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_root_manager_drain_race: consumer thread must complete within 5s [F7: bounded completion]"); + + drain_thread + .join() + .expect("W_root_manager_drain_race: drain_all thread panicked"); + deny_thread + .join() + .expect("W_root_manager_drain_race: deny thread panicked"); + consumer_thread + .join() + .expect("W_root_manager_drain_race: consumer thread panicked"); + + // Explicit disarm: _hook_guard's Drop impl also disarms but this + // keeps the intent visible here. Redundant, not harmful. + manager_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_root_manager_drain_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_root_manager_drain_race: consumer must observe the denial payload at the first \ + cancel signal (proves drain_all lifecycle_cancel cannot fire before try_send)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + ); + assert_eq!( + frame, expected, + "W_root_manager_drain_race: queued frame must be the canonical Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_root_manager_drain_race: cancel must be set after both calls" + ); + } + + // ── W_audio_registry_lifecycle_cancel_race: production-wiring witness ──── + // + // Proves that a real `CommunityConnectionRegistry::disconnect_nip_fi()` denial + // payload is visible to the consumer before a concurrent audio `lifecycle_cancel()` + // (as called by heartbeat, forwarding, owner-loss, recv-loop, teardown) fires the + // cancellation token, using actual `CommunityConnectionRegistry::register()` wiring. + // + // This is the audio-specific counterpart to `w_lifecycle_cancel_race`: it uses + // the audio registry (`community_connections`) and the `cancel_race_test_hook` + // (armed inside `CommunityConnectionControl::disconnect_nip_fi`), then fires + // `control.lifecycle_cancel()` on the racing side — the exact call made by + // every converted audio teardown path (heartbeat, forwarding, recv-loop, owner-loss). + // + // Setup: + // 1. Register one connection in `CommunityConnectionRegistry` with proven pubkey + // and terminal sender set. + // 2. Arm `cancel_race_test_hook`: pauses `disconnect_nip_fi` after reason-win + // while holding the lock. + // 3. Consumer: busy-waits for cancel, drains terminal channel. + // 4. Deny thread: `registry.disconnect_nip_fi("test-issuer", &pubkey)` → wins reason → hook fires. + // 5. Main thread: rendezvous, then `control.lifecycle_cancel()` (audio teardown path). + // 6. Verify consumer saw the denial payload. + // + // Mutation evidence (executed): + // Revert `lifecycle_cancel` to bare `cancel.cancel()` → + // audio teardown cancel fires before `disconnect_nip_fi`'s `try_send` → + // consumer sees `Err(Empty)` → RED. + // Restore → PASS. + #[test] + fn w_audio_registry_lifecycle_cancel_race_payload_precedes_audio_teardown_cancel() { + use std::sync::Arc; + + let registry = Arc::new(CommunityConnectionRegistry::new()); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::from_u128(0xae)); + let target_pubkey = vec![0xaeu8; 32]; + + let (terminal_tx, terminal_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Mirror what audio_post_auth_register + set_terminal_frame_sender do in + // handle_active_audio_connection: register proven pubkey and terminal sender. + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + control.set_terminal_frame_sender(terminal_tx); + + // Keep guard alive for the duration of the test — drop deregisters. + let _guard = registry.register(Uuid::new_v4(), community, control.clone()); + + let hook_key = control.hook_key; + + // Drop guard: disarms the hook on test exit regardless of panic or + // early return. [F7: failure-path cleanup] + struct HookDisarmCancel(uuid::Uuid); + impl Drop for HookDisarmCancel { + fn drop(&mut self) { + cancel_race_test_hook::disarm(self.0); + } + } + let _hook_guard = HookDisarmCancel(hook_key); + + let (hook_ready_tx, hook_ready_rx) = std::sync::mpsc::channel::<()>(); + let (hook_proceed_tx, hook_proceed_rx_inner) = std::sync::mpsc::channel::<()>(); + let hook_proceed_rx = std::sync::Arc::new(std::sync::Mutex::new(hook_proceed_rx_inner)); + + // Arm cancel_race_test_hook: fires inside disconnect_nip_fi after reason-win, + // while terminal_frame_tx lock is held. + cancel_race_test_hook::arm( + hook_key, + Arc::new(move || { + hook_ready_tx.send(()).unwrap(); + hook_proceed_rx.lock().unwrap() + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_audio_registry_lifecycle_cancel_race hook: main thread did not send proceed within 5 s"); + }), + ); + + let (consumer_done_tx, consumer_done_rx) = std::sync::mpsc::channel::<()>(); + let (deny_done_tx, deny_done_rx) = std::sync::mpsc::channel::<()>(); + let (lc_done_tx, lc_done_rx) = std::sync::mpsc::channel::<()>(); + + // Consumer: wakes on first cancel, immediately drains terminal channel. + // Bounded by a 10-second deadline so a broken cancel path fails fast + // rather than hanging the test suite. [F7: bounded worker lifetime] + let cancel_for_consumer = cancel.clone(); + let consumer_result: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); + let consumer_result_for_thread = Arc::clone(&consumer_result); + let mut terminal_rx_for_consumer = terminal_rx; + let consumer_thread = std::thread::spawn(move || { + let stop = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !cancel_for_consumer.is_cancelled() { + if std::time::Instant::now() >= stop { + panic!( + "W_audio_registry_lifecycle_cancel_race: consumer did not observe \ + cancel within 10 s — broken cancellation path" + ); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let r = terminal_rx_for_consumer.try_recv(); + *consumer_result_for_thread.lock().unwrap() = Some(r); + let _ = consumer_done_tx.send(()); + }); + + // Deny thread: real audio registry path. + let deny_thread = std::thread::spawn({ + let registry = Arc::clone(®istry); + let target_pubkey = target_pubkey.clone(); + move || { + registry.disconnect_nip_fi("test-issuer", &target_pubkey); + let _ = deny_done_tx.send(()); + } + }); + + // Wait for deny to reach the hook (won reason, lock held). + hook_ready_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_audio_registry_lifecycle_cancel_race: hook never fired within 5 s — \ + mutation: disconnect_nip_fi hook path never reached", + ); + + // F7 fix: spawn lifecycle_cancel on an independent worker so the main + // thread remains free to send `proceed` to the hook. lifecycle_cancel + // blocks on the transition lock (held by the deny thread) — this is + // what we are proving. Calling it directly on main while the hook + // holds the lock waiting for main's proceed creates a circular wait. + // + // Arm the entry hook BEFORE spawning so the receiver is ready before + // the thread can fire it. Drop guard: if the test panics before + // lc_thread fires the hook, the slot is cleaned up. + // [F7: bounded arrival coordination; F7: failure-path entry-hook cleanup] + let arrival_rx = lifecycle_cancel_entry_hook::arm(hook_key); + struct LcEntryHookGuardAudio(uuid::Uuid); + impl Drop for LcEntryHookGuardAudio { + fn drop(&mut self) { + lifecycle_cancel_entry_hook::disarm(self.0); + } + } + let _lc_entry_guard = LcEntryHookGuardAudio(hook_key); + let control_for_lc = control.clone(); + let lc_thread = std::thread::spawn(move || { + // FIXED: lifecycle_cancel acquires the lock → blocks until deny's + // try_send completes → consumer always sees the frame. + // MUTATION: lifecycle_cancel calls cancel.cancel() bare → audio + // teardown fires cancel before deny's try_send → consumer sees + // Err(Empty) → RED. + control_for_lc.lifecycle_cancel(); + let _ = lc_done_tx.send(()); + }); + + // Wait for the lc_thread to enter lifecycle_cancel (observed via + // arrival hook, not assumed via sleep). At this point the thread has + // fired the hook and is about to acquire the lock, which the deny hook + // still holds. The blocked/contending state is established before we + // release. [F7: bounded arrival coordination] + arrival_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect( + "W_audio_registry_lifecycle_cancel_race: lifecycle_cancel_entry_hook never fired — \ + lc_thread did not reach lifecycle_cancel within 5 s", + ); + + // Allow the hook to proceed (deny's try_send can now complete, then + // drops the lock so lifecycle_cancel can acquire it). + hook_proceed_tx.send(()).unwrap(); + + lc_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_audio_registry_lifecycle_cancel_race: lc_thread must complete within 5s [F7: bounded completion]"); + deny_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_audio_registry_lifecycle_cancel_race: deny thread must complete within 5s [F7: bounded completion]"); + consumer_done_rx.recv_timeout(std::time::Duration::from_secs(5)) + .expect("W_audio_registry_lifecycle_cancel_race: consumer thread must complete within 5s [F7: bounded completion]"); + + lc_thread + .join() + .expect("W_audio_registry_lifecycle_cancel_race: lifecycle_cancel thread panicked"); + deny_thread + .join() + .expect("W_audio_registry_lifecycle_cancel_race: deny thread panicked"); + consumer_thread + .join() + .expect("W_audio_registry_lifecycle_cancel_race: consumer thread panicked"); + + // Explicit disarm: _hook_guard's Drop impl also disarms but this + // keeps the intent visible here. Redundant, not harmful. + cancel_race_test_hook::disarm(hook_key); + + let consumer_saw = consumer_result + .lock() + .unwrap() + .take() + .expect("W_audio_registry_lifecycle_cancel_race: consumer thread must have run"); + + let frame = consumer_saw.expect( + "W_audio_registry_lifecycle_cancel_race: consumer must observe the denial payload \ + at the first cancel signal (proves audio lifecycle_cancel cannot fire before \ + deny's try_send under the fix)", + ); + let expected = crate::nip_fi_session::authorization_denied_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + ); + assert_eq!( + frame, expected, + "W_audio_registry_lifecycle_cancel_race: frame must be the canonical Audio denial frame" + ); + assert!( + cancel.is_cancelled(), + "W_audio_registry_lifecycle_cancel_race: cancel must be set after both calls" + ); + } } diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs index a0b9f685374..28c2d5b7e8b 100644 --- a/crates/buzz-relay/src/test_support.rs +++ b/crates/buzz-relay/src/test_support.rs @@ -8,6 +8,27 @@ pub(crate) fn database_url() -> String { .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) } +/// Database URL exported per test by `scripts/postgres-test-wrapper.sh`. +/// +/// Panics instead of falling back to a shared local database: a missing URL is +/// a lane misconfiguration and must fail loudly, never skip. +#[cfg(test)] +pub(crate) fn required_database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .expect("postgres_tests require the wrapper's per-test DATABASE_URL (run via scripts/postgres-test-run.sh)") +} + +/// Redis URL for PostgreSQL-lane tests that also need Redis; the PG CI job +/// exports `REDIS_URL`. Panics when unset — never skips. +#[cfg(test)] +pub(crate) fn required_redis_url() -> String { + std::env::var("BUZZ_TEST_REDIS_URL") + .or_else(|_| std::env::var("REDIS_URL")) + .expect("this postgres_tests witness requires BUZZ_TEST_REDIS_URL or REDIS_URL") +} + #[cfg(test)] const CHILD_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[cfg(test)] diff --git a/desktop/src/features/huddle/lib/__fixtures__/nip-fi-join-liveness.json b/desktop/src/features/huddle/lib/__fixtures__/nip-fi-join-liveness.json new file mode 100644 index 00000000000..18b9d687cb8 --- /dev/null +++ b/desktop/src/features/huddle/lib/__fixtures__/nip-fi-join-liveness.json @@ -0,0 +1,44 @@ +{ + "_comment": "Normalized NIP-FI relay producer output (48101 JOIN from the real admission handler, 48104 liveness from handle_huddle_liveness_req) for the Desktop contract witness. Placeholders: relay signer=c*64, member=b*64, channel/session=aaaaaaaa-..., generation=1111..., admission_id=2222...; id/sig/created_at are stable dummies. `start` is test scaffolding (48100 is client-authored, not relay output). Drift guard: f3_wire_admission_generation_matches_liveness_source_body in crates/buzz-relay/src/audio/handler.rs normalizes real producer output and fails if join/liveness kind, pubkey, tags, or content diverge from this file.", + "relayPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "creatorPubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "memberPubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "channelId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "sessionId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "generation": "11111111-1111-4111-8111-111111111111", + "admissionId": "22222222-2222-4222-8222-222222222222", + "rosterRevision": 1, + "start": { + "id": "0000000000000000000000000000000000000000000000000000000000000001", + "kind": 48100, + "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "created_at": 1000, + "tags": [["h", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"]], + "content": "{\"ephemeral_channel_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "join": { + "id": "0000000000000000000000000000000000000000000000000000000000000002", + "kind": 48101, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 1001, + "tags": [ + ["h", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ], + "content": "{\"ephemeral_channel_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"roster_revision\":1,\"admission_id\":\"22222222-2222-4222-8222-222222222222\",\"generation\":\"11111111-1111-4111-8111-111111111111\"}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "liveness": { + "id": "0000000000000000000000000000000000000000000000000000000000000003", + "kind": 48104, + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "created_at": 1002, + "tags": [ + ["d", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + ["h", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"] + ], + "content": "{\"ephemeral_channel_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"generation\":\"11111111-1111-4111-8111-111111111111\"}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/desktop/src/features/huddle/lib/huddlePresenceRuntime.test.mjs b/desktop/src/features/huddle/lib/huddlePresenceRuntime.test.mjs index a8e2ca02d33..d940fd2503c 100644 --- a/desktop/src/features/huddle/lib/huddlePresenceRuntime.test.mjs +++ b/desktop/src/features/huddle/lib/huddlePresenceRuntime.test.mjs @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; import test from "node:test"; import { startHuddlePresenceRuntime } from "./huddlePresenceRuntime.ts"; @@ -1638,3 +1641,68 @@ test("liveness refresh clears equal-revision admissions across generations", asy assert.equal(snapshots.at(-1).has(BOB), false); dispose(); }); + +// F3 Desktop contract witness (Carl P2 at cea34b1ca): relay-produced JOIN and +// liveness events, normalized into a committed fixture that the Rust drift guard +// (f3_wire_admission_generation_matches_liveness_source_body) pins to real +// producer output, run through the actual hydrated runtime. The expected member +// is independent of the fixture so a wrong producer p-tag cannot pass. +const F3_EXPECTED_MEMBER = "b".repeat(64); + +test("F3 Desktop contract: producer JOIN + liveness retain admitted participant through authoritative refresh", async () => { + const fixture = JSON.parse( + readFileSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "__fixtures__", + "nip-fi-join-liveness.json", + ), + "utf8", + ), + ); + let livenessTimer; + let livenessFetches = 0; + const snapshots = []; + + const dispose = startHuddlePresenceRuntime({ + relaySelfPubkey: fixture.relayPubkey, + channelIds: [fixture.channelId], + subscribeLive: async () => () => {}, + fetchEvents: async (filter) => { + if (filter.kinds?.includes(48104)) { + livenessFetches += 1; + return [fixture.liveness]; + } + return [fixture.start, fixture.join]; + }, + subscribeToReconnects: () => () => {}, + onPresence: (participants) => snapshots.push(new Set(participants)), + setLivenessTimer: (callback) => { + livenessTimer = callback; + return callback; + }, + clearLivenessTimer: () => { + livenessTimer = undefined; + }, + nowSeconds: () => fixture.liveness.created_at, + }); + + await settle(); + assert.equal(snapshots.at(-1)?.has(F3_EXPECTED_MEMBER), true); + assert.equal(typeof livenessTimer, "function"); + const fetchesBeforeRefresh = livenessFetches; + + livenessTimer(); + await settle(); + + assert.ok( + livenessFetches > fetchesBeforeRefresh, + "authoritative liveness refresh must fetch liveness", + ); + assert.equal( + snapshots.at(-1)?.has(F3_EXPECTED_MEMBER), + true, + "admitted member must remain in presence after authoritative refresh", + ); + dispose(); +}); diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index fee7909dfda..6cfd664baf2 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -838,6 +838,7 @@ is unavailable. | unknown or community-unauthorized issuer; malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; body `evidence rejected\n` | | assertion–key mismatch; unauthorized issuer principal, signed-target–body mismatch, or replayed `jti` on a signed command; active deny-set entry for pubkey | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` | | required JWKS snapshot or community/Host resolution unavailable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; body `authorization unavailable\n` | +| relay in `deny_protected` mode (operator-declared repair mode) | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; same contract as JWKS-unavailable — client evidence may be valid, service is temporarily offline | A denial decided on a WebSocket upgrade is the HTTP response in place of `101`. A denial decided on a protected HTTP request is the HTTP response. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index a3d843b1834..998d62b884d 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -164,6 +164,122 @@ run_unit_tests() { run_test_step "buzz-relay storage snapshot tests" \ cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture + + # NIP-FI (S3/S4) relay witnesses: mirrors the nextest stanza in `just + # test-unit` — the two lists must stay in step. Split into two steps: + # + # 1. Wholly-new modules (nip_fi_* and api::nip_fi) selected by substring + # filter. Filters go after `--`; libtest ORs multiple filters after `--`. + # 2. Sixty-eight exact mixed-module names (state, connection, audio::handler, + # router, handlers::*, config) passed with --exact so each filter is an + # exact match. The exact list keeps main's unselected tests in those + # modules out of this step; they include the ~30s sqlx acquire timeout + # tests that don't belong in the infra-free unit job. + # + # Postgres-backed NIP-FI witnesses run in the PostgreSQL lane, not here. + run_test_step "buzz-relay NIP-FI module tests" \ + cargo test -p buzz-relay --lib -- --nocapture \ + nip_fi_config:: nip_fi_gate:: nip_fi_session:: nip_fi_upgrade:: api::nip_fi:: + + # NIP-FI mixed-module exact-name list. The array is defined here so the + # guard and the run step share one authoritative list. Each entry must match + # a test that exists in buzz-relay --lib; a renamed or deleted entry silently + # matches nothing under libtest, so a discovery guard checks the count before + # running. Keep this list in step with the `just test-unit` nextest stanza + # (Justfile:478-548). + NIP_FI_EXACT_NAMES=( + audio::handler::tests::admin_disconnect_nip_fi_delivers_restricted_json_then_policy_close + audio::handler::tests::audio_expiry_sends_exact_restricted_frame_before_close + audio::handler::tests::b1_already_expired_session_denied_at_pairing_before_admission + audio::handler::tests::b1_mid_admission_expiry_does_not_add_peer_to_room + audio::handler::tests::cw6_guard_release_before_commit_calls_directory_release_exactly_once + audio::handler::tests::cw7_guard_release_before_commit_sends_clean_close_on_remote_stream + audio::handler::tests::handle_active_audio_connection_pairing_mismatch_runs_full_audio_denial_path + audio::handler::tests::pre_send_loop_check_cancel_emits_restricted_json_then_policy_close + audio::handler::tests::w8_membership_check_barrier_fires_before_db_read + audio::handler::tests::w_admin_disconnect_at_deny_check_delivers_payload_then_close + audio::handler::tests::w_audio_deny_absent_key_passes_deny_check_reaches_membership_gate + audio::handler::tests::w_audio_deny_active_key_refused_at_post_registration_check + audio::handler::tests::w_audio_deny_straddle_entry_inserted_between_registration_and_check_is_caught + config::tests::hermetic_for_test_is_deterministic + connection::tests::b2_cancelled_connection_event_frame_not_dispatched + connection::tests::b3_denial_precedes_restart_when_denial_already_won + connection::tests::b3_expiry_denial_precedes_close_through_send_loop + connection::tests::b3_root_pairing_denial_precedes_close_through_send_loop + connection::tests::deadline_exp_is_earliest_selects_exp + connection::tests::deadline_max_connection_lifetime_is_earliest_selects_partition + connection::tests::deadline_no_lifetime_returns_upstream_only + connection::tests::expiry_notice_queued_on_ctrl_before_cancel + connection::tests::r1_denial_precedes_restart_reason_won_frame_arrives_during_recv + handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated + handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path + handlers::event::tests::w_observer_permit_cancel_before_acquisition_blocks_publication + router::tests::b4_connection_upgrade_only_no_upgrade_header_not_gated + router::tests::b4_upgrade_only_no_connection_header_not_gated + router::tests::deny_map_admits_key_not_in_map + router::tests::deny_map_blocks_ws_admission_for_live_entry + router::tests::nip_fi_enforce_audio_denies_missing_assertion_401 + router::tests::nip_fi_enforce_audio_denies_token_when_no_verifier_503 + router::tests::nip_fi_enforce_nip11_content_negotiation_serves_200_not_401 + router::tests::nip_fi_enforce_root_denies_missing_assertion_401 + router::tests::nip_fi_enforce_root_denies_token_when_no_verifier_503 + router::tests::nip_fi_enforce_ws_upgrade_with_html_accept_is_gated_401 + state::tests::auth_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason + state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection + state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped + state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason + state::tests::delete_wins_reason_losing_auth_does_not_enqueue_frame + state::tests::delete_wins_reason_losing_expiry_does_not_enqueue_frame + state::tests::delete_wins_reason_losing_manager_does_not_enqueue_frame + state::tests::delete_wins_reason_losing_pairing_does_not_enqueue_frame + state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame + state::tests::disconnect_nip_fi_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::drain_all_jittered_cancels_when_restart_channel_is_full_or_closed + state::tests::drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling + state::tests::expiry_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::lifecycle_cancel_does_not_enqueue_frame_but_cancels_token + state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::nip_fi_disconnect_audio_is_issuer_scoped + state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason + state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer + state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket + state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket + state::tests::nip_fi_disconnect_then_community_keeps_authorization_denied_reason + state::tests::pairing_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::w_audio_registry_lifecycle_cancel_race_payload_precedes_audio_teardown_cancel + state::tests::w_auth_cancel_race_payload_precedes_community_cancel + state::tests::w_cancel_race_deny_payload_precedes_community_cancel + state::tests::w_expiry_cancel_race_payload_precedes_community_cancel + state::tests::w_lifecycle_cancel_race_payload_precedes_lifecycle_cancel + state::tests::w_lifecycle_cancel_race_reverse_manager_loses_after_lifecycle_wins + state::tests::w_manager_cancel_race_payload_precedes_community_cancel + state::tests::w_pairing_cancel_race_payload_precedes_community_cancel + state::tests::w_root_manager_drain_race_payload_precedes_drain_lifecycle_cancel + ) + + # Guard: every name in the array must resolve to exactly one test. A stale + # name silently matches nothing under libtest, so check the listed count + # equals the array length before running. Fails the summary if any name is + # missing or mistyped. + local _listed + _listed=$(cargo test -p buzz-relay --lib -- --list --exact \ + "${NIP_FI_EXACT_NAMES[@]}" 2>/dev/null | grep -c ': test$' || true) + if [[ "${_listed}" -ne "${#NIP_FI_EXACT_NAMES[@]}" ]]; then + error "buzz-relay NIP-FI exact-name guard FAILED: listed ${_listed}/${#NIP_FI_EXACT_NAMES[@]} tests; a name is stale or missing" + FAILED+=("buzz-relay NIP-FI exact-name guard") + else + log "buzz-relay NIP-FI exact-name guard: ${_listed}/${#NIP_FI_EXACT_NAMES[@]} names resolved" + run_test_step "buzz-relay NIP-FI mixed-module tests" \ + cargo test -p buzz-relay --lib -- --nocapture --exact \ + "${NIP_FI_EXACT_NAMES[@]}" + fi + + # buzz-pubsub unit tests (NIP-FI disconnect fan-out codec): infra-free, + # mirrors the nextest path in `just test-unit` — the two lists must stay + # in step. + run_test_step "buzz-pubsub unit tests" \ + cargo test -p buzz-pubsub --lib -- --nocapture } # ---- DB / integration tests (infra required) --------------------------------