diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b7e5df8d711..c4adb7fc63c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -738,6 +738,8 @@ pub struct ConnectionState {
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
```
+**Community FI admission:** Protected HTTP and root/audio WebSocket requests read the normalized Host's active authentication policy from the primary database. Stored `requires_nip_fi` OR global enforce requires FI; repair mode denies first. HTTP middleware shares one request-local policy read with handlers. Root NIP-11 and `/info` publish a no-store host-specific requirement and compatible capability fields. Policy lookup failure is unavailable, never optional. See [community auth upgrades](docs/community-auth-upgrades.md); completed activation additionally requires existing-session drain evidence.
+
**HTTP endpoints:**
| Method | Path | Handler |
diff --git a/Justfile b/Justfile
index cb186312dfe..acdbfb0fe6d 100644
--- a/Justfile
+++ b/Justfile
@@ -554,7 +554,7 @@ test-unit:
# subscription::tests covers the in-memory registry and its fan-out
# indexes; it is infra-free.
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
- -E 'test(/^subscription::tests::/) + test(/^api::admin::/) + test(/^access::tests::/) + test(/^api::git::transport::access_log_tests::/) + test(=api::bridge::postgres_tests::nip98_replay_check_logs_only_its_refusal) + test(=api::bridge::postgres_tests::warn_only_artifact_read_lines_keep_the_filter_summary) + test(=connection::tests::close_totals_count_frames_the_writer_flushed) + test(=connection::tests::dispatcher_counts_each_req_and_count_frame_once) + test(=handlers::req::tests::unqueued_eose_logs_aborted_not_served) + test(=api::bridge::postgres_tests::reject_code_keeps_only_a_known_prefix) + test(=api::bridge::postgres_tests::nip98_replay_check_fails_closed_when_guard_errors) + test(=api::bridge::postgres_tests::verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host) + test(=api::bridge::postgres_tests::verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints) + test(=api::bridge::postgres_tests::verify_bridge_auth_accepts_nip98_event_signed_for_matching_host) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_verifies_with_matching_query) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_rejected_against_bare_path) + test(=api::bridge::postgres_tests::moderation_read_audit_query_bearing_nip98_event_verifies) + test(=api::bridge::postgres_tests::moderation_read_restricted_no_query_still_verifies) + test(=connection::tests::aborting_lifecycle_owner_is_drop_safe) + test(=connection::tests::shutdown_terminalizes_auth_stalled_on_database_before_late_success) + test(=connection::terminal_close_delivery_tests::upgrade_would_deny_line_without_auth_logs_absent_at_close) + test(=handlers::event::tests::observer_owner_cache_is_scoped_to_community) + test(=state::tests::shared_counter_between_direct_and_fanout) + test(=connection::tests::startup_cancellation_writes_one_close_line) + test(=connection::tests::connection_limit_refusal_writes_one_close_line) + test(=connection::tests::dropped_authenticated_handler_writes_one_close_line) + test(=connection::tests::close_totals_count_a_batch_flushed_by_the_drain_after_cancellation) + test(=connection::tests::close_totals_skip_a_frame_the_socket_rejects) + test(=connection::tests::close_totals_skip_frames_stranded_by_a_failed_flush) + test(=connection::tests::read_before_login_logs_one_denial_from_the_handler) + test(=connection::tests::read_refused_by_a_full_handler_semaphore_logs_one_denial) + test(=connection::tests::read_refused_by_unavailable_admission_logs_one_denial) + test(=handlers::auth::tests::repeat_auth_after_a_terminal_logs_one_denial) + test(=nip_fi_upgrade::tests::deny_protected_logs_one_denied_auth_line) + test(=api::bridge::moderation_refusal_tests::moderation_lookup_failure_is_unavailable) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::bridge::search_slots::tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
+ -E 'test(/^subscription::tests::/) + test(/^api::admin::/) + test(/^access::tests::/) + test(/^api::git::transport::access_log_tests::/) + test(=api::bridge::postgres_tests::nip98_replay_check_logs_only_its_refusal) + test(=api::bridge::postgres_tests::warn_only_artifact_read_lines_keep_the_filter_summary) + test(=connection::tests::close_totals_count_frames_the_writer_flushed) + test(=connection::tests::dispatcher_counts_each_req_and_count_frame_once) + test(=handlers::req::tests::unqueued_eose_logs_aborted_not_served) + test(=api::bridge::postgres_tests::reject_code_keeps_only_a_known_prefix) + test(=api::bridge::postgres_tests::nip98_replay_check_fails_closed_when_guard_errors) + test(=api::bridge::postgres_tests::verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host) + test(=api::bridge::postgres_tests::verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints) + test(=api::bridge::postgres_tests::verify_bridge_auth_accepts_nip98_event_signed_for_matching_host) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_verifies_with_matching_query) + test(=api::bridge::postgres_tests::moderation_read_query_bearing_nip98_event_rejected_against_bare_path) + test(=api::bridge::postgres_tests::moderation_read_audit_query_bearing_nip98_event_verifies) + test(=api::bridge::postgres_tests::moderation_read_restricted_no_query_still_verifies) + test(=connection::tests::aborting_lifecycle_owner_is_drop_safe) + test(=connection::tests::shutdown_terminalizes_auth_stalled_on_database_before_late_success) + test(=connection::terminal_close_delivery_tests::upgrade_would_deny_line_without_auth_logs_absent_at_close) + test(=handlers::event::tests::observer_owner_cache_is_scoped_to_community) + test(=state::tests::shared_counter_between_direct_and_fanout) + test(=connection::tests::startup_cancellation_writes_one_close_line) + test(=connection::tests::connection_limit_refusal_writes_one_close_line) + test(=connection::tests::dropped_authenticated_handler_writes_one_close_line) + test(=connection::tests::close_totals_count_a_batch_flushed_by_the_drain_after_cancellation) + test(=connection::tests::close_totals_skip_a_frame_the_socket_rejects) + test(=connection::tests::close_totals_skip_frames_stranded_by_a_failed_flush) + test(=connection::tests::read_before_login_logs_one_denial_from_the_handler) + test(=connection::tests::read_refused_by_a_full_handler_semaphore_logs_one_denial) + test(=connection::tests::read_refused_by_unavailable_admission_logs_one_denial) + test(=handlers::auth::tests::repeat_auth_after_a_terminal_logs_one_denial) + test(=nip_fi_upgrade::tests::deny_protected_logs_one_denied_auth_line) + test(=api::bridge::moderation_refusal_tests::moderation_lookup_failure_is_unavailable) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_policy::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::bridge::search_slots::tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs
index 967d6c3719a..403de76dbdf 100644
--- a/crates/buzz-auth/src/nip_fi/startup/mod.rs
+++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs
@@ -29,6 +29,23 @@ pub enum NipFiMode {
}
impl NipFiMode {
+ /// Enforce stored community requirements only in Shadow; Off disables FI.
+ /// Repair mode remains unconditional.
+ pub const fn with_requirement(self, required: bool) -> Self {
+ if self.denies_unconditionally() {
+ self
+ } else if self.observes_only() && required {
+ Self::Enforce
+ } else {
+ self
+ }
+ }
+
+ /// Fail closed when a dependency needed to decide policy is unavailable.
+ pub const fn unavailable() -> Self {
+ Self::DenyProtected
+ }
+
/// NIP-FI is disabled.
pub const fn is_off(self) -> bool {
matches!(self, Self::Off)
diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs
index 123289ee189..4cb89441b0a 100644
--- a/crates/buzz-auth/src/nip_fi/startup/tests.rs
+++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs
@@ -247,3 +247,26 @@ fn mode_predicates_separate_evaluation_from_authority() {
assert_eq!(mode.observes_only(), evaluates && !restricts, "{mode:?}");
}
}
+
+#[test]
+fn community_requirements_follow_relay_mode() {
+ for stored in [false, true] {
+ assert_eq!(NipFiMode::Off.with_requirement(stored), NipFiMode::Off);
+ assert_eq!(
+ NipFiMode::Shadow.with_requirement(stored),
+ if stored {
+ NipFiMode::Enforce
+ } else {
+ NipFiMode::Shadow
+ }
+ );
+ assert_eq!(
+ NipFiMode::Enforce.with_requirement(stored),
+ NipFiMode::Enforce
+ );
+ assert_eq!(
+ NipFiMode::DenyProtected.with_requirement(stored),
+ NipFiMode::DenyProtected
+ );
+ }
+}
diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs
index 0a2b1d457bd..53d6c598d29 100644
--- a/crates/buzz-db/src/store/community.rs
+++ b/crates/buzz-db/src/store/community.rs
@@ -8,6 +8,12 @@ use uuid::Uuid;
use crate::{relay_members, Db, DbError, ReadAt, Result};
+// Admission and policy must select the same serving row. A deletion request
+// alone does not stop serving; begin_quiescing changes deletion_state.
+pub(super) const SERVING_HOST_QUERY: &str = "SELECT id, host, requires_nip_fi, policy_generation
+ FROM communities WHERE lower(host) = lower($1)
+ AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active'";
+
/// Community host-map row returned by [`Db::lookup_community_by_host`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityRecord {
@@ -133,19 +139,10 @@ impl Db {
)
.await?;
let read_at = std::time::Instant::now();
- let row = sqlx::query(
- r#"
- SELECT id, host
- FROM communities
- WHERE lower(host) = lower($1)
- AND archived_at IS NULL
- AND deleted_at IS NULL
- AND deletion_state = 'active'
- "#,
- )
- .bind(normalized_host)
- .fetch_optional(&mut *connection)
- .await?;
+ let row = sqlx::query(SERVING_HOST_QUERY)
+ .bind(normalized_host)
+ .fetch_optional(&mut *connection)
+ .await?;
let value = row
.map(|row| {
diff --git a/crates/buzz-db/src/store/community_auth.rs b/crates/buzz-db/src/store/community_auth.rs
index a69b908112d..88c6b93edfc 100644
--- a/crates/buzz-db/src/store/community_auth.rs
+++ b/crates/buzz-db/src/store/community_auth.rs
@@ -11,6 +11,15 @@ use uuid::Uuid;
use crate::{Db, Result};
+/// Fleet protocol bound for a cached host's policy, measured before checkout.
+/// Increasing this requires a coordinated acceptance-writer rollout first.
+pub const POLICY_LEASE_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(1);
+// Allow clock-rate drift between pods. Never shorten below the fleet lease bound.
+const POLICY_LEASE_DRAIN: std::time::Duration =
+ POLICY_LEASE_MAX_AGE.saturating_add(std::time::Duration::from_millis(100));
+// Two-int advisory key space, separate from the existing bigint lock protocols.
+const POLICY_LEASE_NAMESPACE: i32 = 0x4649504c;
+
/// Canonical request tuple, with the authenticated operator added by the relay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpgradeRequest {
@@ -82,6 +91,9 @@ pub enum UpgradeOutcome {
/// Authoritative policy for a community in the active lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRow)]
pub struct CommunityAuthPolicy {
+ /// Immutable identity from the same row as this policy.
+ #[sqlx(rename = "id")]
+ pub community_id: Uuid,
/// Durable monotonic requirement. Global enforce remains an additional floor.
pub requires_nip_fi: bool,
/// Zero for legacy optional communities; positive after the first enable.
@@ -106,6 +118,99 @@ const RECEIPT: &str = "SELECT u.*, true AS requires_nip_fi,
WHERE u.operation_id = $1";
impl Db {
+ /// Read a cacheable serving policy under the host's shared lease lock.
+ ///
+ /// Callers must anchor expiry BEFORE invoking this method and refuse an
+ /// expired result. Acceptance excludes new leases and drains old ones before
+ /// committing. The lock and row read MUST be separate READ COMMITTED
+ /// statements: a snapshot from before lock acquisition is not a lease.
+ /// Contention refuses immediately; it never leaves a server-side lock wait.
+ pub async fn community_auth_policy_lease_for_host(
+ &self,
+ host: &str,
+ ) -> Result