From dc43209fd29d7b5379bf891a3dee4d9a5f481524 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:45:02 -0500 Subject: [PATCH 01/11] fix(auth): harden assertion and provider evidence Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/context/binding.rs | 37 + crates/buzz-auth/src/context/evidence.rs | 373 +++- crates/buzz-auth/src/context/mod.rs | 237 ++- crates/buzz-auth/src/context/reason.rs | 12 + crates/buzz-auth/src/context/tests.rs | 105 +- crates/buzz-auth/src/evidence_adapter.rs | 676 ++++++++ crates/buzz-auth/src/lease.rs | 834 +++++++++ crates/buzz-auth/src/lib.rs | 25 +- crates/buzz-auth/src/provider/mod.rs | 5 + crates/buzz-relay/src/api/bridge.rs | 1369 +++++++++++---- .../src/authorization_runtime/transport.rs | 1420 +++++++++++++++ crates/buzz-relay/src/config.rs | 21 +- crates/buzz-relay/src/corporate_identity.rs | 1517 +++++++++++++++-- crates/buzz-relay/src/metrics.rs | 28 + 14 files changed, 6019 insertions(+), 640 deletions(-) create mode 100644 crates/buzz-auth/src/evidence_adapter.rs create mode 100644 crates/buzz-auth/src/lease.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/transport.rs diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index e9ca0e9c9e..2bb56754ce 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -601,6 +601,43 @@ impl VersionedBindingRef { } } + pub(crate) fn from_evidence_adapter( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + resolution_reason: AuthorizationReason, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + let valid_reason = matches!( + (source, resolution_reason), + (_, AuthorizationReason::ExistingBinding) + | ( + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey + ) + | (BindingSource::Tofu, AuthorizationReason::EnrolledTofu) + ); + if !valid_reason { + return Err(AuthContextError::InvalidAuthorizationReason); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason, + }) + } + /// Build a reference to a binding authoritatively resolved as already active. #[cfg(test)] pub(crate) fn new_existing_active_for_test( diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs index c99018a4b0..a7b3580de9 100644 --- a/crates/buzz-auth/src/context/evidence.rs +++ b/crates/buzz-auth/src/context/evidence.rs @@ -1,14 +1,12 @@ -use std::fmt; +use std::{fmt, sync::Arc}; use buzz_core::CommunityId; use nostr::PublicKey; use uuid::Uuid; -use crate::Scope; +use crate::{provider::AuthorizationProfileId, provider::CapabilitySet, Scope}; -#[cfg(test)] -use super::transport_accepts_proof; -use super::AuthContextError; +use super::{transport_accepts_proof, AuthContextError}; /// Cryptographic proof used to authenticate the Nostr actor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,7 +15,7 @@ pub enum AuthMethod { Nip42, /// NIP-98 signed HTTP request. Nip98, - /// Blossom upload authorization. + /// Blossom media authorization for the exact verified operation. Blossom, } @@ -192,9 +190,7 @@ impl AdmissionExpiry { /// Server-verified Nostr authority for a request or connection. #[derive(PartialEq, Eq)] pub struct NostrAuthority { - actor_pubkey: PublicKey, - proof_method: AuthMethod, - verified_delegation: Option, + verified_proof: Arc, } impl fmt::Debug for NostrAuthority { @@ -202,42 +198,42 @@ impl fmt::Debug for NostrAuthority { formatter .debug_struct("NostrAuthority") .field("actor_pubkey", &"[redacted]") - .field("proof_method", &self.proof_method) + .field("proof_method", &self.verified_proof.proof_method()) .field("verified_delegation", &"[redacted]") .finish() } } impl NostrAuthority { - pub(super) fn new(proof: VerifiedNostrProof) -> Self { - Self { - actor_pubkey: proof.actor_pubkey, - proof_method: proof.proof_method, - verified_delegation: proof.verified_delegation, - } + pub(super) fn new(verified_proof: Arc) -> Self { + Self { verified_proof } } /// Authenticated Nostr actor. - pub const fn actor_pubkey(&self) -> PublicKey { - self.actor_pubkey + pub fn actor_pubkey(&self) -> PublicKey { + self.verified_proof.actor_pubkey() } /// Proof method used to authenticate the actor. - pub const fn proof_method(&self) -> AuthMethod { - self.proof_method + pub fn proof_method(&self) -> AuthMethod { + self.verified_proof.proof_method() } /// Cryptographically verified owner for a delegated Nostr actor. - pub const fn verified_owner_pubkey(&self) -> Option { - match &self.verified_delegation { - Some(delegation) => Some(delegation.owner_pubkey()), - None => None, - } + pub fn verified_owner_pubkey(&self) -> Option { + self.verified_proof + .verified_delegation() + .map(VerifiedTransportDelegation::owner_pubkey) } /// Cryptographically verified owner-to-actor delegation, when present. - pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { - self.verified_delegation.as_ref() + pub fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_proof.verified_delegation() + } + + /// Sealed proof retained from transport verification. + pub fn verified_proof(&self) -> &Arc { + &self.verified_proof } } @@ -304,6 +300,10 @@ pub struct VerifiedKeyAttestation { } impl VerifiedKeyAttestation { + pub(crate) const fn from_evidence_adapter(pubkey: PublicKey) -> Self { + Self { pubkey } + } + #[cfg(test)] pub(crate) const fn new(pubkey: PublicKey) -> Self { Self { pubkey } @@ -346,6 +346,26 @@ pub struct VerifiedFederatedAssertion { } impl VerifiedFederatedAssertion { + pub(crate) const fn from_evidence_adapter( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, + ) -> Self { + Self { + authorization_domain, + authorized_transport, + principal, + key_attestation, + transport, + not_before, + expires_at, + } + } + #[cfg(test)] pub(crate) const fn new( authorization_domain: CommunityId, @@ -418,6 +438,140 @@ impl fmt::Debug for VerifiedFederatedAssertion { } } +/// Provider-neutral identity and capability evidence from a verified adapter. +/// +/// This move-only value can be created only from an already sealed federated +/// assertion. It carries the installed server profile, normalized portable +/// capabilities, and a hard freshness window without retaining raw headers, +/// tokens, or provider-specific claims. +#[derive(PartialEq, Eq)] +pub struct VerifiedProviderEvidence { + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, +} + +impl VerifiedProviderEvidence { + pub(crate) const fn from_evidence_adapter( + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, + ) -> Self { + Self { + assertion, + profile_id, + capabilities, + issued_at, + fresh_until, + } + } + + /// Authorization domain for which the provider evidence was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.assertion.authorization_domain() + } + + /// Protected transport for which the provider evidence was verified. + pub const fn authorized_transport(&self) -> AuthTransport { + self.assertion.authorized_transport() + } + + /// Normalized issuer-qualified principal from the verified provenance. + pub const fn principal(&self) -> &FederatedPrincipal { + self.assertion.principal() + } + + /// Installed server profile that produced this evidence. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Normalized capabilities granted by the verified provider result. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Server-observed issuance time for this provider result. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Exclusive hard freshness bound for this provider result. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Revalidate this evidence for one exact protected request. + pub fn validate_for( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + installed_profile: &AuthorizationProfileId, + required_capabilities: &CapabilitySet, + now_unix_seconds: u64, + ) -> Result<(), ProviderEvidenceValidationError> { + if self.authorization_domain() != authorization_domain { + return Err(ProviderEvidenceValidationError::AuthorizationDomainMismatch); + } + if self.authorized_transport() != transport { + return Err(ProviderEvidenceValidationError::TransportMismatch); + } + if self.profile_id() != installed_profile { + return Err(ProviderEvidenceValidationError::ProfileMismatch); + } + if !required_capabilities + .as_slice() + .iter() + .all(|capability| self.capabilities.contains(*capability)) + { + return Err(ProviderEvidenceValidationError::CapabilityMismatch); + } + if self.issued_at > now_unix_seconds { + return Err(ProviderEvidenceValidationError::NotYetValid); + } + if self.fresh_until <= now_unix_seconds { + return Err(ProviderEvidenceValidationError::Expired); + } + Ok(()) + } +} + +impl fmt::Debug for VerifiedProviderEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedProviderEvidence") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Fail-closed reason provider evidence cannot authorize an exact request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderEvidenceValidationError { + /// Evidence belongs to another authorization domain. + AuthorizationDomainMismatch, + /// Evidence was verified for another protected transport. + TransportMismatch, + /// Evidence was produced by another installed provider profile. + ProfileMismatch, + /// Evidence does not grant every capability required by the request. + CapabilityMismatch, + /// Evidence claims an issuance time after the server clock. + NotYetValid, + /// Evidence has reached its hard freshness bound. + Expired, +} + /// Current admission for the owner of a delegated Nostr actor. /// /// This evidence is independent of a federated assertion: a delegated request @@ -434,9 +588,7 @@ pub struct VerifiedOwnerAdmission { } impl VerifiedOwnerAdmission { - // Consumed by the provider finalizer in the stacked capability contract. - #[allow(dead_code)] - pub(crate) const fn new( + pub(crate) const fn from_capability_snapshot( authorization_domain: CommunityId, principal: FederatedPrincipal, fresh_until: AdmissionExpiry, @@ -448,6 +600,15 @@ impl VerifiedOwnerAdmission { } } + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, + ) -> Self { + Self::from_capability_snapshot(authorization_domain, principal, fresh_until) + } + /// Authorization domain for which owner admission was resolved. pub const fn authorization_domain(&self) -> CommunityId { self.authorization_domain @@ -491,6 +652,13 @@ impl fmt::Debug for DelegationCapability { } } +impl DelegationCapability { + /// Whether the verifier proved authority for the complete transport. + pub const fn is_transport_wide(self) -> bool { + matches!(self, Self::TransportWide) + } +} + /// Transport-wide delegation from a bound owner to the authenticated key. /// /// A verifier may construct this only after proving the capability authorizes @@ -522,7 +690,6 @@ impl fmt::Debug for VerifiedTransportDelegation { impl VerifiedTransportDelegation { /// Build transport-wide evidence after validating both keys and confirming /// that no narrower capability constraint is being discarded. - #[cfg(test)] pub(crate) fn new_unrestricted( owner_pubkey: PublicKey, delegate_pubkey: PublicKey, @@ -560,6 +727,78 @@ impl VerifiedTransportDelegation { } } +/// Class of exact verifier input retained by a sealed Nostr proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifiedOperationBindingKind { + /// NIP-42 challenge and relay URL for a WebSocket session. + NostrSession, + /// NIP-98 method, URL, and payload for an HTTP request. + HttpRequest, + /// Blossom upload verb, blob hash, server, and signed event. + BlossomUpload, + /// Blossom download verb, blob hash, server, and signed event. + BlossomDownload, +} + +/// Opaque exact-operation fingerprint produced only by a trusted verifier. +/// +/// It has no public constructor or serialization path. The fingerprint keeps +/// sensitive URLs, challenges, and payloads out of the authorization context +/// while preventing a proof from being silently widened to another verifier +/// operation class. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct VerifiedOperationBinding { + kind: VerifiedOperationBindingKind, + fingerprint: [u8; 32], +} + +impl VerifiedOperationBinding { + pub(crate) const fn from_evidence_adapter( + kind: VerifiedOperationBindingKind, + fingerprint: [u8; 32], + ) -> Self { + Self { kind, fingerprint } + } + + #[cfg(test)] + fn for_transport(transport: AuthTransport) -> Self { + let kind = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => { + VerifiedOperationBindingKind::NostrSession + } + AuthTransport::HttpBridge | AuthTransport::Git => { + VerifiedOperationBindingKind::HttpRequest + } + AuthTransport::MediaUpload => VerifiedOperationBindingKind::BlossomUpload, + AuthTransport::MediaDownload => VerifiedOperationBindingKind::BlossomDownload, + }; + Self { + kind, + fingerprint: [0; 32], + } + } + + /// Exact verifier-operation class. + pub const fn kind(self) -> VerifiedOperationBindingKind { + self.kind + } + + /// Opaque fingerprint of the exact verified operation inputs. + pub const fn fingerprint(self) -> [u8; 32] { + self.fingerprint + } +} + +impl fmt::Debug for VerifiedOperationBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedOperationBinding") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .finish() + } +} + /// Cryptographically verified Nostr proof for one request or connection. /// /// Transport verifiers inside `buzz-auth` produce this evidence after checking @@ -574,10 +813,41 @@ pub struct VerifiedNostrProof { authorized_transport: AuthTransport, actor_pubkey: PublicKey, proof_method: AuthMethod, + operation_binding: VerifiedOperationBinding, verified_delegation: Option, } impl VerifiedNostrProof { + pub(crate) fn from_evidence_adapter( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + operation_binding: VerifiedOperationBinding, + verified_delegation: Option, + ) -> Result { + if !transport_accepts_proof(authorized_transport, proof_method) { + return Err(AuthContextError::TransportProofMismatch); + } + if !operation_binding_matches_transport(operation_binding.kind(), authorized_transport) { + return Err(AuthContextError::OperationProofMismatch); + } + if verified_delegation + .as_ref() + .is_some_and(|delegation| delegation.delegate_pubkey() != actor_pubkey) + { + return Err(AuthContextError::DelegateKeyMismatch); + } + Ok(Self { + authorization_domain, + authorized_transport, + actor_pubkey, + proof_method, + operation_binding, + verified_delegation, + }) + } + #[cfg(test)] pub(crate) fn new( authorization_domain: CommunityId, @@ -600,6 +870,7 @@ impl VerifiedNostrProof { authorized_transport, actor_pubkey, proof_method, + operation_binding: VerifiedOperationBinding::for_transport(authorized_transport), verified_delegation, }) } @@ -624,6 +895,11 @@ impl VerifiedNostrProof { self.proof_method } + /// Opaque binding to the exact verifier operation. + pub const fn operation_binding(&self) -> VerifiedOperationBinding { + self.operation_binding + } + /// Verified owner-to-actor delegation, when present. pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { self.verified_delegation.as_ref() @@ -638,11 +914,34 @@ impl fmt::Debug for VerifiedNostrProof { .field("authorized_transport", &self.authorized_transport) .field("actor_pubkey", &"[redacted]") .field("proof_method", &self.proof_method) + .field("operation_binding", &self.operation_binding) .field("verified_delegation", &"[redacted]") .finish() } } +const fn operation_binding_matches_transport( + kind: VerifiedOperationBindingKind, + transport: AuthTransport, +) -> bool { + matches!( + (kind, transport), + ( + VerifiedOperationBindingKind::NostrSession, + AuthTransport::RelayWebSocket | AuthTransport::Audio + ) | ( + VerifiedOperationBindingKind::HttpRequest, + AuthTransport::HttpBridge | AuthTransport::Git + ) | ( + VerifiedOperationBindingKind::BlossomUpload, + AuthTransport::MediaUpload + ) | ( + VerifiedOperationBindingKind::BlossomDownload, + AuthTransport::MediaDownload + ) + ) +} + /// Successful community admission and permissions for one decision. /// /// An authorization adapter may construct this value only after membership, @@ -660,6 +959,18 @@ pub struct AuthorizedCommunityAccess { } impl AuthorizedCommunityAccess { + pub(crate) const fn from_evidence_adapter( + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Self { + Self { + authorization_domain, + scopes, + channel_ids, + } + } + #[cfg(test)] pub(crate) const fn new( authorization_domain: CommunityId, diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index 95883be426..ce24e54390 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -5,13 +5,19 @@ //! the Nostr authority that signed the request. Raw assertions and mutable //! display claims never enter this type. -use std::fmt; +use std::{fmt, sync::Arc}; use buzz_core::{tenant::TenantContext, CommunityId}; use nostr::PublicKey; use uuid::Uuid; -use crate::Scope; +use crate::{ + lease::{ + AuthorizationLease, AuthorizationLeaseValidator, AuthorizationOperationGuard, + LeaseUseRequirement, LeaseValidationError, + }, + Scope, +}; pub(crate) mod authority; mod binding; @@ -32,8 +38,10 @@ pub use binding::{ pub use evidence::{ AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, - FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, - VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, + FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError, + VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, + VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission, + VerifiedProviderEvidence, VerifiedTransportDelegation, }; pub use reason::{AuthContextError, AuthorizationReason}; @@ -133,6 +141,7 @@ pub struct AuthContextV1 { nostr: NostrAuthority, federated_policy: ResolvedFederatedPolicy, federated: FederatedAuthorization, + authorization_lease: Option, scopes: Vec, channel_ids: Option>, } @@ -142,7 +151,7 @@ pub struct AuthContextV1 { pub struct AuthContextInput { tenant: TenantContext, correlation_id: Uuid, - nostr_proof: VerifiedNostrProof, + nostr_proof: Arc, community_access: AuthorizedCommunityAccess, } @@ -177,24 +186,23 @@ impl AuthContextInput { pub fn new( tenant: TenantContext, correlation_id: Uuid, - nostr_proof: VerifiedNostrProof, + nostr_proof: impl Into>, community_access: AuthorizedCommunityAccess, ) -> Self { Self { tenant, correlation_id, - nostr_proof, + nostr_proof: nostr_proof.into(), community_access, } } - #[allow(dead_code)] pub(crate) const fn authorization_domain(&self) -> CommunityId { self.tenant.community() } #[allow(dead_code)] - pub(crate) const fn nostr_proof_authorization_domain(&self) -> CommunityId { + pub(crate) fn nostr_proof_authorization_domain(&self) -> CommunityId { self.nostr_proof.authorization_domain() } @@ -204,32 +212,47 @@ impl AuthContextInput { } #[allow(dead_code)] - pub(crate) const fn correlation_id(&self) -> Uuid { - self.correlation_id - } - - #[allow(dead_code)] - pub(crate) const fn transport(&self) -> AuthTransport { + pub(crate) fn transport(&self) -> AuthTransport { self.nostr_proof.authorized_transport() } #[allow(dead_code)] - pub(crate) const fn proof_method(&self) -> AuthMethod { + pub(crate) fn proof_method(&self) -> AuthMethod { self.nostr_proof.proof_method() } #[allow(dead_code)] - pub(crate) const fn actor_pubkey(&self) -> PublicKey { + pub(crate) fn actor_pubkey(&self) -> PublicKey { self.nostr_proof.actor_pubkey() } #[allow(dead_code)] - pub(crate) const fn verified_owner_pubkey(&self) -> Option { + pub(crate) fn verified_owner_pubkey(&self) -> Option { match self.nostr_proof.verified_delegation() { Some(delegation) => Some(delegation.owner_pubkey()), None => None, } } + + /// Server-resolved tenant carried by the finalization input. + pub const fn tenant(&self) -> &TenantContext { + &self.tenant + } + + /// Correlation identifier for this authorization decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Cryptographically verified Nostr proof. + pub fn nostr_proof(&self) -> &VerifiedNostrProof { + &self.nostr_proof + } + + /// Current community admission and permissions. + pub const fn community_access(&self) -> &AuthorizedCommunityAccess { + &self.community_access + } } impl fmt::Debug for AuthContextV1 { @@ -242,6 +265,7 @@ impl fmt::Debug for AuthContextV1 { .field("nostr", &self.nostr) .field("federated_policy", &self.federated_policy) .field("federated", &self.federated) + .field("authorization_lease", &"[redacted]") .field("scopes", &"[redacted]") .field("channel_ids", &"[redacted]") .finish() @@ -305,7 +329,13 @@ impl AuthContext { } } }; - Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds) + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, + now_unix_seconds, + ) } /// Validate all authorization evidence and finalize an immutable V1 context. @@ -324,26 +354,75 @@ impl AuthContext { authorization: FederatedAuthorization, now_unix_seconds: u64, ) -> Result { - let authorization_domain = input.tenant.community(); - let transport = input.nostr_proof.authorized_transport(); - if input.nostr_proof.authorization_domain() != authorization_domain { - return Err(AuthContextError::NostrProofDomainMismatch); - } - validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; - if input.community_access.authorization_domain() != authorization_domain { - return Err(AuthContextError::CommunityAccessDomainMismatch); + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, + now_unix_seconds, + ) + } + + pub(crate) fn finalize_v1_with_lease( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + authorization_lease: AuthorizationLease, + now_unix_seconds: u64, + ) -> Result { + if !matches!( + federated_policy.requirement(), + FederatedIdentityRequirement::Required(_) + ) || matches!(authorization, FederatedAuthorization::NotRequired) + { + return Err(AuthContextError::FinalizedLeaseMismatch); } - if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { - return Err(AuthContextError::TransportProofMismatch); + let active_binding = authorization + .active_binding() + .ok_or(AuthContextError::FinalizedLeaseMismatch)?; + if authorization_lease.authorization_domain() != input.tenant.community() + || authorization_lease.transport() != input.nostr_proof.authorized_transport() + || authorization_lease.actor_pubkey() != input.nostr_proof.actor_pubkey() + || authorization_lease.binding_id() != active_binding.binding_id() + || authorization_lease.binding_version() != active_binding.binding_version() + || authorization_lease.correlation_id() != input.correlation_id + { + return Err(AuthContextError::FinalizedLeaseMismatch); } - validate_federated_authorization( - authorization_domain, - transport, - &input.nostr_proof, - &federated_policy, - &authorization, + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + Some(authorization_lease), + now_unix_seconds, + ) + } + + #[cfg(test)] + pub(super) fn finalize_v1_evidence_for_test( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + now_unix_seconds: u64, + ) -> Result { + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, now_unix_seconds, - )?; + ) + } + + fn finalize_v1_inner( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + authorization_lease: Option, + now_unix_seconds: u64, + ) -> Result { + validate_context_evidence(&input, &federated_policy, &authorization, now_unix_seconds)?; + let transport = input.nostr_proof.authorized_transport(); let nostr = NostrAuthority::new(input.nostr_proof); let (scopes, channel_ids) = input.community_access.into_permissions(); Ok(Self::V1(AuthContextV1 { @@ -353,6 +432,7 @@ impl AuthContext { nostr, federated_policy, federated: authorization, + authorization_lease, scopes, channel_ids, })) @@ -394,17 +474,17 @@ impl AuthContext { } /// Authenticated Nostr actor. - pub const fn pubkey(&self) -> PublicKey { + pub fn pubkey(&self) -> PublicKey { self.nostr().actor_pubkey() } /// Proof method used to authenticate the Nostr actor. - pub const fn auth_method(&self) -> AuthMethod { + pub fn auth_method(&self) -> AuthMethod { self.nostr().proof_method() } /// Cryptographically verified owner for a delegated Nostr actor. - pub const fn agent_owner_pubkey(&self) -> Option { + pub fn agent_owner_pubkey(&self) -> Option { self.nostr().verified_owner_pubkey() } @@ -422,6 +502,43 @@ impl AuthContext { } } + /// Bounded federated access lease, when this is an enforcing context. + pub const fn authorization_lease(&self) -> Option<&AuthorizationLease> { + match self { + Self::V1(context) => context.authorization_lease.as_ref(), + } + } + + /// Validate this context's access lease for one protected operation. + /// + /// Nostr-only contexts have no federated lease and therefore fail closed + /// when presented to a federated-protected operation. + pub fn authorize_lease_use( + &self, + validator: &AuthorizationLeaseValidator, + requirement: &LeaseUseRequirement, + ) -> Result<(), LeaseValidationError> { + let lease = self + .authorization_lease() + .ok_or(LeaseValidationError::MissingLease)?; + validator.authorize(lease, requirement) + } + + /// Validate and retain a per-capability guard for an in-flight operation. + /// + /// Callers must revalidate the returned guard immediately before a + /// protected commit or stream emission. + pub fn operation_guard<'a>( + &'a self, + validator: &AuthorizationLeaseValidator, + requirement: LeaseUseRequirement, + ) -> Result, LeaseValidationError> { + let lease = self + .authorization_lease() + .ok_or(LeaseValidationError::MissingLease)?; + validator.operation_guard(lease, requirement) + } + /// Stable reason for the successful authorization decision. pub const fn authorization_reason(&self) -> AuthorizationReason { match self.federated_authorization() { @@ -474,6 +591,46 @@ fn validate_federated_policy_stamp( Ok(()) } +pub(crate) fn validate_context_evidence( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + authorization: &FederatedAuthorization, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + validate_federated_policy_stamp(input, federated_policy, now_unix_seconds)?; + let authorization_domain = input.tenant.community(); + let transport = input.nostr_proof.authorized_transport(); + if input.nostr_proof.authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + if federated_policy.authorization_domain() != authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch); + } + if input.community_access.authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { + return Err(AuthContextError::TransportProofMismatch); + } + validate_federated_authorization( + authorization_domain, + transport, + &input.nostr_proof, + federated_policy, + authorization, + now_unix_seconds, + ) +} + +impl FederatedAuthorization { + pub(crate) const fn active_binding(&self) -> Option<&VersionedBindingRef> { + match self { + Self::NotRequired => None, + Self::Direct { binding, .. } => Some(binding), + Self::Delegated { owner, .. } => Some(owner), + } + } +} pub(super) const fn transport_accepts_proof( transport: AuthTransport, proof_method: AuthMethod, @@ -486,7 +643,7 @@ pub(super) const fn transport_accepts_proof( AuthTransport::MediaUpload => { matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom) } - AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98), + AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Blossom), } } diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index fe07e9a68f..eba2f4356c 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -154,6 +154,9 @@ pub enum AuthContextError { /// Proof method was not valid for the transport being authorized. #[error("Nostr proof method does not match authorization transport")] TransportProofMismatch, + /// Exact verifier-operation evidence was not valid for the transport. + #[error("Nostr operation proof does not match authorization transport")] + OperationProofMismatch, /// Direct federated authorization was attached to a delegated Nostr actor. #[error("direct federated authorization cannot include a delegated Nostr owner")] DirectAuthorizationHasOwner, @@ -172,6 +175,12 @@ pub enum AuthContextError { /// Delegated owner evidence did not resolve an already-active binding. #[error("delegated federated authorization requires an existing active binding")] DelegatedBindingNotExistingActive, + /// Federated finalization did not consume a current provider decision. + #[error("federated authorization requires a current provider decision")] + ProviderDecisionRequired, + /// An issued lease did not match the context evidence being finalized. + #[error("authorization lease does not match finalized context evidence")] + FinalizedLeaseMismatch, } impl AuthContextError { @@ -214,6 +223,7 @@ impl AuthContextError { Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch", Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch", Self::TransportProofMismatch => "nostr_transport_proof_mismatch", + Self::OperationProofMismatch => "nostr_operation_proof_mismatch", Self::DirectAuthorizationHasOwner => "federated_direct_has_owner", Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch", Self::DelegateKeyMismatch => "federated_delegate_key_mismatch", @@ -222,6 +232,8 @@ impl AuthContextError { Self::DelegatedBindingNotExistingActive => { "federated_delegated_binding_not_existing_active" } + Self::ProviderDecisionRequired => "authorization_provider_decision_required", + Self::FinalizedLeaseMismatch => "authorization_finalized_lease_mismatch", } } } diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs index 528e04e5f5..d444239274 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -353,7 +353,7 @@ fn delegated_authorization( fn context_preserves_server_resolved_authority() { let keys = Keys::generate(); let correlation_id = Uuid::from_u128(2); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), correlation_id, @@ -398,7 +398,7 @@ fn context_preserves_server_resolved_authority() { fn direct_authorization_requires_the_authenticated_key() { let actor = Keys::generate(); let other = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -415,7 +415,7 @@ fn direct_authorization_requires_the_authenticated_key() { fn delegated_authorization_requires_the_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -437,7 +437,7 @@ fn delegated_authorization_requires_the_verified_owner() { fn delegated_authorization_requires_verified_delegation() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), delegated_authorization(1, owner.public_key(), principal(), 200), @@ -482,7 +482,7 @@ fn principal_preserves_exact_validated_values() { fn context_debug_output_omits_tenant_host() { let actor = Keys::generate(); let channel_id = Uuid::from_u128(20); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -522,6 +522,7 @@ fn context_debug_output_omits_tenant_host() { "correlation_id: \"[redacted]\", requirement: \"[redacted]\", ", "effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ", "federated: FederatedAuthorization(\"[redacted]\"), ", + "authorization_lease: \"[redacted]\", ", "scopes: \"[redacted]\", channel_ids: \"[redacted]\" })" ) ); @@ -531,7 +532,7 @@ fn context_debug_output_omits_tenant_host() { fn direct_authorization_rejects_a_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -553,7 +554,7 @@ fn direct_authorization_rejects_a_verified_owner() { fn delegated_authorization_requires_current_owner_admission() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -572,7 +573,7 @@ fn delegated_authorization_requires_current_owner_admission() { fn delegated_authorization_rejects_cross_domain_owner_admission() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -600,7 +601,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() { let owner = Keys::generate(); let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject") .expect("synthetic principal is valid"); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -619,7 +620,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() { fn delegated_authorization_rejects_an_expired_proof() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input_with_delegation_expiry( actor.public_key(), AuthTransport::RelayWebSocket, @@ -663,7 +664,7 @@ fn delegated_authorization_requires_the_bound_owner() { let actor = Keys::generate(); let owner = Keys::generate(); let other_owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -682,7 +683,7 @@ fn delegated_authorization_requires_the_bound_owner() { fn delegated_binding_cannot_cross_authorization_domains() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -832,7 +833,7 @@ fn verified_assertion_debug_output_is_fully_redacted() { #[test] fn direct_authorization_rejects_expired_assertions() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -850,7 +851,7 @@ fn direct_authorization_rejects_expired_assertions() { #[test] fn direct_authorization_rejects_binding_at_exact_expiry() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -869,7 +870,7 @@ fn direct_authorization_rejects_binding_at_exact_expiry() { fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { let owner = Keys::generate(); let delegate = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( delegate.public_key(), AuthTransport::RelayWebSocket, @@ -894,7 +895,7 @@ fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { #[test] fn direct_authorization_rejects_a_future_assertion() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -919,7 +920,7 @@ fn direct_authorization_rejects_a_future_assertion() { #[test] fn direct_authorization_requires_the_assertion_principal() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -942,7 +943,7 @@ fn direct_authorization_requires_the_assertion_principal() { #[test] fn enrolled_reason_must_match_policy_and_binding_source() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Provisioned), FederatedAuthorization::Direct { @@ -981,7 +982,7 @@ fn existing_active_bindings_are_independent_of_enrollment_mode() { for enrollment_mode in enrollment_modes { for binding_source in binding_sources { - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(enrollment_mode), FederatedAuthorization::Direct { @@ -1038,7 +1039,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { let actor = Keys::generate(); let other = Keys::generate(); - let missing = AuthContext::finalize_v1( + let missing = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1054,7 +1055,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { .expect_err("attested enrollment cannot silently accept a missing key claim"); assert_eq!(missing, AuthContextError::KeyAttestationRequired); - let mismatch = AuthContext::finalize_v1( + let mismatch = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1075,7 +1076,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { .expect_err("attested enrollment cannot accept another Nostr key"); assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1104,7 +1105,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { fn present_key_attestation_never_ignores_an_actor_mismatch() { let actor = Keys::generate(); let other = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), FederatedAuthorization::Direct { @@ -1140,7 +1141,7 @@ fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() { ), }; - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), authorization, @@ -1308,7 +1309,7 @@ fn security_posture_debug_output_is_fully_redacted() { #[test] fn federated_policy_must_match_the_authorization_correlation() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200), FederatedAuthorization::Direct { @@ -1325,7 +1326,7 @@ fn federated_policy_must_match_the_authorization_correlation() { #[test] fn federated_policy_effective_interval_is_half_open() { let actor = Keys::generate(); - let not_yet_effective = AuthContext::finalize_v1( + let not_yet_effective = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200), FederatedAuthorization::Direct { @@ -1340,7 +1341,7 @@ fn federated_policy_effective_interval_is_half_open() { AuthContextError::FederatedPolicyNotYetEffective ); - let expired = AuthContext::finalize_v1( + let expired = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100), FederatedAuthorization::Direct { @@ -1736,7 +1737,7 @@ fn authoritative_finalizer_requires_existing_active_delegated_owner() { #[test] fn tofu_enrollment_cannot_use_attested_key_policy_reason() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), FederatedAuthorization::Direct { @@ -1769,17 +1770,37 @@ fn transport_and_proof_method_must_agree() { } #[test] -fn blossom_upload_proof_cannot_authorize_media_downloads() { +fn verified_blossom_proof_authorizes_media_download_profile() { let actor = Keys::generate(); - let error = VerifiedNostrProof::new( + let proof = VerifiedNostrProof::new( authorization_domain(1), AuthTransport::MediaDownload, actor.public_key(), AuthMethod::Blossom, None, ) - .expect_err("upload-only proof must not be widened to media download authority"); + .expect("verified Blossom GET/HEAD proof matches the media download profile"); + assert_eq!(proof.authorized_transport(), AuthTransport::MediaDownload); + assert_eq!(proof.proof_method(), AuthMethod::Blossom); +} + +#[test] +fn nip98_proof_cannot_substitute_for_blossom_media_download() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaDownload, + actor.public_key(), + AuthMethod::Nip98, + None, + ) + .expect_err("no implemented NIP-98 media GET/HEAD verifier exists"); assert_eq!(error, AuthContextError::TransportProofMismatch); +} + +#[test] +fn blossom_media_proof_remains_bound_to_its_exact_operation() { + let actor = Keys::generate(); VerifiedNostrProof::new( authorization_domain(1), @@ -1788,13 +1809,13 @@ fn blossom_upload_proof_cannot_authorize_media_downloads() { AuthMethod::Blossom, None, ) - .expect("Blossom proof may authorize the verified upload operation"); + .expect("Blossom upload proof may authorize only its verified upload operation"); } #[test] fn binding_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1813,7 +1834,7 @@ fn binding_cannot_cross_authorization_domains() { fn nostr_only_authorization_may_preserve_a_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -1859,7 +1880,7 @@ fn transport_delegation_is_explicitly_transport_wide() { #[test] fn required_policy_rejects_nostr_only_authorization() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::NotRequired, @@ -1874,7 +1895,7 @@ fn required_policy_rejects_nostr_only_authorization() { #[test] fn not_required_policy_rejects_federated_authorization() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_not_required(), FederatedAuthorization::Direct { @@ -1892,7 +1913,7 @@ fn not_required_policy_rejects_federated_authorization() { #[test] fn nostr_proof_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -1912,7 +1933,7 @@ fn nostr_proof_cannot_cross_authorization_domains() { #[test] fn federated_policy_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), ResolvedFederatedPolicy::not_required(authorization_domain(2)), FederatedAuthorization::NotRequired, @@ -1927,7 +1948,7 @@ fn federated_policy_cannot_cross_authorization_domains() { #[test] fn community_admission_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -1947,7 +1968,7 @@ fn community_admission_cannot_cross_authorization_domains() { #[test] fn assertion_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1971,7 +1992,7 @@ fn assertion_cannot_cross_authorization_domains() { #[test] fn assertion_must_match_the_authorized_transport() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1996,7 +2017,7 @@ fn assertion_must_match_the_authorized_transport() { fn delegated_owner_admission_must_match_the_authorization_domain() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, diff --git a/crates/buzz-auth/src/evidence_adapter.rs b/crates/buzz-auth/src/evidence_adapter.rs new file mode 100644 index 0000000000..5346053e44 --- /dev/null +++ b/crates/buzz-auth/src/evidence_adapter.rs @@ -0,0 +1,676 @@ +//! Narrow trusted-workspace adapter for sealed authorization evidence. +//! +//! Wire data cannot deserialize into any type in this module. The adapter is +//! used only after the existing cryptographic verifier, assertion verifier, +//! community policy, or typed binding store has returned success. It repeats +//! exact domain, transport, actor, time, identifier, version, and provenance +//! checks before crossing the crate's sealed evidence boundary. + +use buzz_core::{tenant::TenantContext, CommunityId}; +use nostr::{Event, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthContextError, AuthMethod, + AuthTransport, AuthorizedCommunityAccess, BindingExpiry, BindingSource, BindingVersion, + DelegationExpiry, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedOperationBinding, VerifiedOperationBindingKind, + VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, + }, + nip42::verify_nip42_event, + nip98::verify_nip98_event, + AuthorizationProfileId, AuthorizationReason, CapabilitySet, Scope, +}; + +/// Binding lifecycle result returned by an authoritative store adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActiveBindingResolution { + /// Exact active binding already existed. + Existing, + /// Binding was atomically enrolled during this decision. + Enrolled, +} + +/// Existing-verifier output for transport-wide Nostr delegation. +/// +/// Construction is deliberately explicit and non-serializable. The relay may +/// create it only by translating a successful NIP-OA verifier result whose +/// constraints were proved transport-wide; raw `auth` tag data is not such a +/// result. +pub struct VerifiedDelegationOutput { + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + transport_wide: bool, +} + +impl VerifiedDelegationOutput { + /// Translate an existing verifier's exact output. + pub const fn from_workspace_verifier( + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + transport_wide: bool, + ) -> Self { + Self { + owner_pubkey, + delegate_pubkey, + expires_at, + transport_wide, + } + } +} + +/// Stateless factory for sealed evidence. +/// +/// This value carries no configuration and cannot select a domain, provider, +/// or capability. Every method requires the server-resolved values again and +/// rejects mismatches in the translated verifier/store output. +#[derive(Debug, Default, Clone, Copy)] +pub struct VerifiedEvidenceAdapter; + +fn operation_binding( + kind: VerifiedOperationBindingKind, + parts: &[&[u8]], +) -> VerifiedOperationBinding { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-auth:verified-operation-binding:v1"); + for part in parts { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + VerifiedOperationBinding::from_evidence_adapter(kind, hasher.finalize().into()) +} + +impl VerifiedEvidenceAdapter { + /// Create the trusted-workspace adapter. + pub const fn new() -> Self { + Self + } + + /// Attach exact transport-wide delegation output to an already sealed + /// cryptographic proof. + /// + /// This consumes the original proof, rejects replacement of an existing + /// delegation, and rechecks that the verified delegate is the proof actor. + pub fn attach_transport_delegation( + &self, + proof: VerifiedNostrProof, + delegation: VerifiedDelegationOutput, + ) -> Result { + if proof.verified_delegation().is_some() { + return Err(EvidenceAdapterError::DelegationAlreadyPresent); + } + let verified_delegation = self.delegation(proof.actor_pubkey(), delegation)?; + VerifiedNostrProof::from_evidence_adapter( + proof.authorization_domain(), + proof.authorized_transport(), + proof.actor_pubkey(), + proof.proof_method(), + proof.operation_binding(), + Some(verified_delegation), + ) + .map_err(Into::into) + } + + /// Verify NIP-42 and bind its result to relay or audio transport. + pub fn verify_nip42( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + event: &Event, + expected_challenge: &str, + relay_url: &str, + delegation: Option, + ) -> Result { + if !matches!( + transport, + AuthTransport::RelayWebSocket | AuthTransport::Audio + ) { + return Err(EvidenceAdapterError::TransportMethodMismatch); + } + verify_nip42_event(event, expected_challenge, relay_url)?; + let delegation = delegation + .map(|delegation| self.delegation(event.pubkey, delegation)) + .transpose()?; + let binding = operation_binding( + VerifiedOperationBindingKind::NostrSession, + &[expected_challenge.as_bytes(), relay_url.as_bytes()], + ); + VerifiedNostrProof::from_evidence_adapter( + authorization_domain, + transport, + event.pubkey, + AuthMethod::Nip42, + binding, + delegation, + ) + .map_err(Into::into) + } + + /// Verify NIP-98 and bind it to one exact HTTP transport operation. + // The individual arguments are intentional trust-boundary inputs: folding + // them into an unverified request bag would make it easier to omit an exact + // domain, transport, method, body, or delegation cross-check. + #[allow(clippy::too_many_arguments)] + pub fn verify_nip98( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + event_json: &str, + expected_url: &str, + expected_method: &str, + body: Option<&[u8]>, + delegation: Option, + ) -> Result { + if !matches!( + transport, + AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaUpload + ) { + return Err(EvidenceAdapterError::TransportMethodMismatch); + } + let actor = verify_nip98_event(event_json, expected_url, expected_method, body)?; + let delegation = delegation + .map(|delegation| self.delegation(actor, delegation)) + .transpose()?; + let body_presence = [u8::from(body.is_some())]; + let binding = operation_binding( + VerifiedOperationBindingKind::HttpRequest, + &[ + expected_method.as_bytes(), + expected_url.as_bytes(), + &body_presence, + body.unwrap_or_default(), + ], + ); + VerifiedNostrProof::from_evidence_adapter( + authorization_domain, + transport, + actor, + AuthMethod::Nip98, + binding, + delegation, + ) + .map_err(Into::into) + } + + fn delegation( + &self, + actor: PublicKey, + output: VerifiedDelegationOutput, + ) -> Result { + if !output.transport_wide { + return Err(EvidenceAdapterError::NarrowDelegation); + } + if output.delegate_pubkey != actor { + return Err(EvidenceAdapterError::DelegatedActorMismatch); + } + let expires_at = output.expires_at.map(DelegationExpiry::new).transpose()?; + VerifiedTransportDelegation::new_unrestricted( + output.owner_pubkey, + output.delegate_pubkey, + expires_at, + ) + .map_err(Into::into) + } + + /// Translate validated assertion claims into sealed, exact-bound evidence. + /// + /// `now_unix_seconds` and every claim argument must be copied from the + /// successful configured assertion-verifier result, never decoded again + /// from request data at this boundary. + #[allow(clippy::too_many_arguments)] + pub fn federated_assertion_from_validated_claims( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + issuer: &str, + subject: &str, + attested_pubkey: Option, + assertion_transport: AssertionTransport, + not_before: Option, + expires_at: u64, + now_unix_seconds: u64, + ) -> Result { + let principal = FederatedPrincipal::new(issuer, subject)?; + let expires_at = AssertionExpiry::new(expires_at)?; + if expires_at.is_expired_at(now_unix_seconds) { + return Err(EvidenceAdapterError::AssertionExpired); + } + let not_before = not_before.map(AssertionNotBefore::new); + if not_before.is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) { + return Err(EvidenceAdapterError::AssertionNotYetValid); + } + let key_attestation = attested_pubkey.map(VerifiedKeyAttestation::from_evidence_adapter); + Ok(VerifiedFederatedAssertion::from_evidence_adapter( + authorization_domain, + transport, + principal, + key_attestation, + assertion_transport, + not_before, + expires_at, + )) + } + + /// Seal provider-neutral evidence from an already verified assertion. + /// + /// The adapter rejects malformed or stale freshness bounds before the + /// evidence can reach a protected route. The profile and capabilities are + /// typed server-side values and cannot be selected by raw request input. + pub fn provider_evidence_from_verified_assertion( + &self, + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, + now_unix_seconds: u64, + ) -> Result { + if issued_at == 0 || issued_at > now_unix_seconds { + return Err(EvidenceAdapterError::ProviderEvidenceNotYetValid); + } + if fresh_until <= issued_at + || fresh_until <= now_unix_seconds + || fresh_until > assertion.expires_at().unix_seconds() + { + return Err(EvidenceAdapterError::ProviderEvidenceExpired); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(EvidenceAdapterError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(EvidenceAdapterError::AssertionExpired); + } + Ok(VerifiedProviderEvidence::from_evidence_adapter( + assertion, + profile_id, + capabilities, + issued_at, + fresh_until, + )) + } + + /// Translate a typed active binding-store result. + /// + /// The adapter derives the authorization reason from lifecycle resolution + /// and provenance. Callers cannot label an enrolled row as pre-existing or + /// turn a provisioned row into a first-use enrollment. + #[allow(clippy::too_many_arguments)] + pub fn active_binding_from_store( + &self, + authorization_domain: CommunityId, + binding_domain: CommunityId, + binding_id: Uuid, + issuer: &str, + subject: &str, + bound_pubkey: PublicKey, + binding_version: u64, + expires_at: Option, + source: BindingSource, + resolution: ActiveBindingResolution, + assertion: Option<&VerifiedFederatedAssertion>, + ) -> Result { + if binding_domain != authorization_domain { + return Err(EvidenceAdapterError::BindingDomainMismatch); + } + let binding_version = BindingVersion::new(binding_version)?; + let expires_at = expires_at.map(BindingExpiry::new).transpose()?; + let principal = FederatedPrincipal::new(issuer, subject)?; + if let Some(assertion) = assertion { + if assertion.authorization_domain() != authorization_domain + || assertion.principal() != &principal + { + return Err(EvidenceAdapterError::AssertionBindingMismatch); + } + if assertion + .key_attestation() + .is_some_and(|key| key.pubkey() != bound_pubkey) + { + return Err(EvidenceAdapterError::AssertionBindingMismatch); + } + } + let reason = match (resolution, source) { + (ActiveBindingResolution::Existing, _) => AuthorizationReason::ExistingBinding, + (ActiveBindingResolution::Enrolled, BindingSource::AttestedKey) => { + if assertion + .and_then(VerifiedFederatedAssertion::key_attestation) + .is_none_or(|key| key.pubkey() != bound_pubkey) + { + return Err(EvidenceAdapterError::AttestationRequired); + } + AuthorizationReason::EnrolledAttestedKey + } + (ActiveBindingResolution::Enrolled, BindingSource::Tofu) => { + AuthorizationReason::EnrolledTofu + } + (ActiveBindingResolution::Enrolled, BindingSource::Provisioned) => { + return Err(EvidenceAdapterError::InvalidBindingResolution) + } + }; + VersionedBindingRef::from_evidence_adapter( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + reason, + ) + .map_err(Into::into) + } + + /// Translate successful local community policy into sealed admission. + pub fn community_access_from_policy( + &self, + tenant: &TenantContext, + resolved_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Result { + if tenant.community() != resolved_domain { + return Err(EvidenceAdapterError::AdmissionDomainMismatch); + } + Ok(AuthorizedCommunityAccess::from_evidence_adapter( + resolved_domain, + scopes, + channel_ids, + )) + } +} + +/// Rejection while translating trusted-workspace verifier/store output. +#[derive(Debug, Error)] +pub enum EvidenceAdapterError { + /// Existing cryptographic proof failed. + #[error(transparent)] + Authentication(#[from] crate::AuthError), + /// Sealed context evidence was inconsistent. + #[error(transparent)] + Context(#[from] AuthContextError), + /// Proof method cannot establish the requested transport. + #[error("verified proof method does not match requested transport")] + TransportMethodMismatch, + /// Delegation output named a different actor. + #[error("verified delegation output does not match authenticated actor")] + DelegatedActorMismatch, + /// Operation-scoped delegation cannot be promoted to transport-wide. + #[error("verified delegation output is narrower than the transport")] + NarrowDelegation, + /// A sealed proof cannot have its verified delegation replaced. + #[error("verified transport delegation is already present")] + DelegationAlreadyPresent, + /// Validated assertion was already expired. + #[error("validated assertion is expired")] + AssertionExpired, + /// Validated assertion is not yet current. + #[error("validated assertion is not yet valid")] + AssertionNotYetValid, + /// Provider evidence claims an issuance time after the server clock. + #[error("verified provider evidence is not yet valid")] + ProviderEvidenceNotYetValid, + /// Provider evidence has an invalid or expired hard freshness bound. + #[error("verified provider evidence is expired")] + ProviderEvidenceExpired, + /// Binding store result came from another exact domain. + #[error("active binding output belongs to another authorization domain")] + BindingDomainMismatch, + /// Binding does not match the validated assertion. + #[error("active binding output does not match validated assertion")] + AssertionBindingMismatch, + /// Attested-key enrollment did not carry the exact attested key. + #[error("attested-key enrollment lacks matching attestation")] + AttestationRequired, + /// Store provenance cannot produce the supplied lifecycle result. + #[error("active binding resolution and provenance are inconsistent")] + InvalidBindingResolution, + /// Local policy result belongs to another exact domain. + #[error("community admission belongs to another authorization domain")] + AdmissionDomainMismatch, +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, RelayUrl}; + + use super::*; + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn neutral_capabilities() -> CapabilitySet { + CapabilitySet::new(vec![crate::AuthorizationCapability::CommunityRead]) + .expect("synthetic capability set") + } + + fn neutral_profile(value: &str) -> AuthorizationProfileId { + AuthorizationProfileId::from_server_configuration(value) + .expect("synthetic provider profile") + } + + #[test] + fn neutral_verified_evidence_accepts_and_rejects_exact_parts() { + let adapter = VerifiedEvidenceAdapter::new(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain(1), + AuthTransport::HttpBridge, + "https://issuer.example", + "subject", + None, + AssertionTransport::TrustedProxy, + Some(90), + 200, + 100, + ) + .expect("synthetic verified assertion"); + let evidence = adapter + .provider_evidence_from_verified_assertion( + assertion, + neutral_profile("profile-a"), + neutral_capabilities(), + 100, + 180, + 100, + ) + .expect("matching verified provider evidence"); + + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Ok(()) + ); + assert_eq!( + evidence.validate_for( + domain(2), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::AuthorizationDomainMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::Git, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::TransportMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-b"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::ProfileMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &CapabilitySet::new(vec![crate::AuthorizationCapability::GitRead]) + .expect("synthetic capability set"), + 100, + ), + Err(crate::ProviderEvidenceValidationError::CapabilityMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 180, + ), + Err(crate::ProviderEvidenceValidationError::Expired) + ); + assert_eq!( + format!("{evidence:?}"), + "VerifiedProviderEvidence { authorization_domain: \"[redacted]\", authorized_transport: \"[redacted]\", principal: \"[redacted]\", profile_id: \"[redacted]\", capabilities: \"[redacted]\", issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }" + ); + } + + #[test] + fn nip42_factory_reverifies_signature_challenge_transport_and_actor() { + let adapter = VerifiedEvidenceAdapter::new(); + let actor = Keys::generate(); + let owner = Keys::generate(); + let challenge = "challenge"; + let relay = "wss://relay.example"; + let event = EventBuilder::auth(challenge, RelayUrl::parse(relay).expect("relay url")) + .sign_with_keys(&actor) + .expect("auth event"); + + let proof = adapter + .verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + relay, + Some(VerifiedDelegationOutput::from_workspace_verifier( + owner.public_key(), + actor.public_key(), + None, + true, + )), + ) + .expect("verified NIP-42 proof"); + assert_eq!( + proof.operation_binding().kind(), + VerifiedOperationBindingKind::NostrSession + ); + let substituted = adapter + .verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + "wss://other.example", + None, + ) + .expect_err("relay URL substitution must fail full verification"); + assert!(matches!( + substituted, + EvidenceAdapterError::Authentication(_) + )); + assert!(matches!( + adapter.verify_nip42( + domain(1), + AuthTransport::Git, + &event, + challenge, + relay, + None, + ), + Err(EvidenceAdapterError::TransportMethodMismatch) + )); + assert!(matches!( + adapter.verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + relay, + Some(VerifiedDelegationOutput::from_workspace_verifier( + owner.public_key(), + Keys::generate().public_key(), + None, + true, + )), + ), + Err(EvidenceAdapterError::DelegatedActorMismatch) + )); + } + + #[test] + fn assertion_and_binding_factories_reject_cross_domain_and_forged_provenance() { + let adapter = VerifiedEvidenceAdapter::new(); + let actor = Keys::generate().public_key(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain(1), + AuthTransport::HttpBridge, + "https://issuer.example", + "subject", + Some(actor), + AssertionTransport::TrustedProxy, + None, + 200, + 100, + ) + .expect("assertion"); + assert!(matches!( + adapter.active_binding_from_store( + domain(1), + domain(2), + Uuid::new_v4(), + "https://issuer.example", + "subject", + actor, + 1, + None, + BindingSource::AttestedKey, + ActiveBindingResolution::Existing, + Some(&assertion), + ), + Err(EvidenceAdapterError::BindingDomainMismatch) + )); + assert!(matches!( + adapter.active_binding_from_store( + domain(1), + domain(1), + Uuid::new_v4(), + "https://issuer.example", + "subject", + actor, + 1, + None, + BindingSource::Provisioned, + ActiveBindingResolution::Enrolled, + Some(&assertion), + ), + Err(EvidenceAdapterError::InvalidBindingResolution) + )); + } +} diff --git a/crates/buzz-auth/src/lease.rs b/crates/buzz-auth/src/lease.rs new file mode 100644 index 0000000000..39d91013a7 --- /dev/null +++ b/crates/buzz-auth/src/lease.rs @@ -0,0 +1,834 @@ +//! Bounded, versioned authorization leases. +//! +//! A lease is issued only by the crate-owned federated finalizer after all +//! identity, provider, delegation, and binding evidence has been validated. +//! Lease consumers must supply the current typed context, lease, binding, +//! provider profile, and policy versions plus the exact capability being +//! exercised. + +use std::{fmt, sync::Arc, time::SystemTime}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{AuthContextVersion, AuthTransport, BindingVersion, VersionedBindingRef}, + provider::{AuthorizationCapability, AuthorizationProfileId, CapabilitySet, PolicyVersion}, +}; + +/// Largest application lease duration accepted by the portable runtime. +pub const MAX_APPLICATION_LEASE_SECONDS: u64 = 86_400; +/// Largest explicit clock-skew allowance accepted by the portable runtime. +pub const MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS: u64 = 300; +/// Largest renewal lead time accepted by the portable runtime. +pub const MAX_LEASE_RENEWAL_LEAD_SECONDS: u64 = 3_600; + +/// Centrally supplied authorization time in Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AuthorizationTime(u64); + +impl AuthorizationTime { + /// Preserve a Unix timestamp supplied by an authorization clock. + pub const fn from_unix_seconds(unix_seconds: u64) -> Self { + Self(unix_seconds) + } + + /// Timestamp as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for AuthorizationTime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationTime") + .field(&"[redacted]") + .finish() + } +} + +/// Centrally injected time source for authorization decisions and lease use. +pub trait AuthorizationClock: Send + Sync { + /// Return the current authorization time. + fn now(&self) -> Result; +} + +/// System-backed authorization clock used outside deterministic tests. +#[derive(Debug, Default)] +pub struct SystemAuthorizationClock; + +impl AuthorizationClock for SystemAuthorizationClock { + fn now(&self) -> Result { + let elapsed = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| AuthorizationClockError::BeforeUnixEpoch)?; + Ok(AuthorizationTime::from_unix_seconds(elapsed.as_secs())) + } +} + +/// Failure to obtain central authorization time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AuthorizationClockError { + /// The system clock was earlier than the Unix epoch. + #[error("authorization clock is earlier than the Unix epoch")] + BeforeUnixEpoch, + /// The injected clock could not provide a current value. + #[error("authorization clock is unavailable")] + Unavailable, +} + +/// Shared injected authorization clock. +pub type SharedAuthorizationClock = Arc; + +/// Explicit conservative clock-skew allowance. +/// +/// Skew is subtracted from every evidence bound when authority is issued; it +/// never extends a lease past an assertion, provider, delegation, binding, or +/// application expiry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationClockSkew(u64); + +impl AuthorizationClockSkew { + /// Build a bounded skew allowance in seconds. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds > MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS { + return Err(LeasePolicyError::ClockSkewTooLarge); + } + Ok(Self(seconds)) + } + + /// Configured conservative skew allowance in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Maximum application lifetime for an access lease or verification status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApplicationLeaseLimit(u64); + +impl ApplicationLeaseLimit { + /// Build a non-zero application lifetime no greater than one day. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds == 0 { + return Err(LeasePolicyError::ZeroApplicationLimit); + } + if seconds > MAX_APPLICATION_LEASE_SECONDS { + return Err(LeasePolicyError::ApplicationLimitTooLarge); + } + Ok(Self(seconds)) + } + + /// Configured maximum lifetime in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Server-owned access-lease bounds for one authorization domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AccessLeasePolicy { + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +} + +impl AccessLeasePolicy { + /// Combine an application maximum with an explicit conservative skew. + pub const fn new( + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, + ) -> Self { + Self { + application_limit, + clock_skew, + } + } + + /// Configured application maximum. + pub const fn application_limit(self) -> ApplicationLeaseLimit { + self.application_limit + } + + /// Configured conservative clock skew. + pub const fn clock_skew(self) -> AuthorizationClockSkew { + self.clock_skew + } +} + +/// Server-owned display-status bounds for verification-only mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerificationStatusPolicy { + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +} + +impl VerificationStatusPolicy { + /// Combine a short display maximum with an explicit conservative skew. + pub const fn new( + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, + ) -> Self { + Self { + application_limit, + clock_skew, + } + } + + /// Configured display-status maximum. + pub const fn application_limit(self) -> ApplicationLeaseLimit { + self.application_limit + } + + /// Configured conservative clock skew. + pub const fn clock_skew(self) -> AuthorizationClockSkew { + self.clock_skew + } +} + +/// Invalid server-owned lease policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeasePolicyError { + /// An application maximum was zero. + #[error("application authorization limit must be greater than zero")] + ZeroApplicationLimit, + /// An application maximum exceeded the portable upper bound. + #[error("application authorization limit exceeds the portable maximum")] + ApplicationLimitTooLarge, + /// The clock-skew allowance exceeded the portable upper bound. + #[error("authorization clock skew exceeds the portable maximum")] + ClockSkewTooLarge, + /// A renewal lead time was zero. + #[error("authorization lease renewal lead must be greater than zero")] + ZeroRenewalLead, + /// A renewal lead time exceeded the portable upper bound. + #[error("authorization lease renewal lead exceeds the portable maximum")] + RenewalLeadTooLarge, +} + +/// Monotonic version of an issued authorization lease. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LeaseVersion(u64); + +impl LeaseVersion { + /// Initial lease version for a newly authorized session. + pub const INITIAL: Self = Self(1); + + /// Build a positive lease version. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(LeaseIssueError::InvalidLeaseVersion); + } + Ok(Self(value)) + } + + /// Numeric lease version. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for LeaseVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("LeaseVersion") + .field(&"[redacted]") + .finish() + } +} + +/// Freshness bound obtained with an authoritative active binding. +/// +/// The identifier and version prevent a bound from one binding snapshot from +/// being reused with another binding or lifecycle version. +#[derive(PartialEq, Eq)] +pub struct BindingLeaseBound { + binding_id: Uuid, + binding_version: BindingVersion, + valid_until: u64, +} + +impl BindingLeaseBound { + /// Attach a non-zero freshness bound to an authoritative active binding. + pub fn new(binding: &VersionedBindingRef, valid_until: u64) -> Result { + if valid_until == 0 { + return Err(LeaseIssueError::InvalidBindingBound); + } + Ok(Self { + binding_id: binding.binding_id(), + binding_version: binding.binding_version(), + valid_until, + }) + } + + /// Stable binding identifier represented by this bound. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact active binding version represented by this bound. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Time after which active binding state must be resolved again. + pub const fn valid_until(&self) -> u64 { + self.valid_until + } +} + +impl fmt::Debug for BindingLeaseBound { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BindingLeaseBound") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("valid_until", &"[redacted]") + .finish() + } +} + +/// Versioned authority for a bounded set of federated capabilities. +/// +/// This type has no public constructor or deserialization path. Only the +/// crate-owned finalizer can issue it from a validated provider snapshot and +/// matching active binding evidence. +#[derive(PartialEq, Eq)] +pub struct AuthorizationLease { + context_version: AuthContextVersion, + lease_version: LeaseVersion, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + capabilities: CapabilitySet, + issued_at: u64, + expires_at: u64, + correlation_id: Uuid, +} + +impl AuthorizationLease { + #[allow(clippy::too_many_arguments)] + pub(crate) fn issue( + lease_version: LeaseVersion, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding: &VersionedBindingRef, + binding_bound: BindingLeaseBound, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + capabilities: CapabilitySet, + provider_effective_until: u64, + now: AuthorizationTime, + policy: AccessLeasePolicy, + correlation_id: Uuid, + ) -> Result { + if binding_bound.binding_id != binding.binding_id() + || binding_bound.binding_version != binding.binding_version() + { + return Err(LeaseIssueError::BindingBoundMismatch); + } + let expires_at = conservative_expiry( + now, + provider_effective_until, + binding_bound.valid_until, + policy.application_limit, + policy.clock_skew, + )?; + Ok(Self { + context_version: AuthContextVersion::V1, + lease_version, + authorization_domain, + transport, + actor_pubkey, + owner_pubkey, + binding_id: binding.binding_id(), + binding_version: binding.binding_version(), + profile_id, + policy_version, + capabilities, + issued_at: now.unix_seconds(), + expires_at, + correlation_id, + }) + } + + /// Authorization-context contract version carried by the lease. + pub const fn context_version(&self) -> AuthContextVersion { + self.context_version + } + + /// Monotonic lease version. + pub const fn lease_version(&self) -> LeaseVersion { + self.lease_version + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact transport authorized by the lease. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// Authenticated Nostr actor authorized by the lease. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Verified delegated owner, when the actor is delegated. + pub const fn owner_pubkey(&self) -> Option { + self.owner_pubkey + } + + /// Stable active binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact active binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Exact capabilities granted by current provider policy. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Central issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Conservative earliest expiry in Unix seconds. + pub const fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Correlation identifier for the decision that issued the lease. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Compute deterministic renewal and hard-expiry deadlines. + pub fn renewal_schedule(&self, lead_time: LeaseRenewalLeadTime) -> LeaseRenewalSchedule { + LeaseRenewalSchedule { + renew_at: self + .expires_at + .saturating_sub(lead_time.seconds()) + .max(self.issued_at), + expires_at: self.expires_at, + } + } +} + +impl fmt::Debug for AuthorizationLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationLease") + .field("context_version", &self.context_version) + .field("lease_version", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport) + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("correlation_id", &"[redacted]") + .finish() + } +} + +/// Current typed state required to consume an authorization lease. +#[derive(Clone)] +pub struct LeaseUseRequirement { + /// Required authorization-context contract version. + pub context_version: AuthContextVersion, + /// Current lease version for the session. + pub lease_version: LeaseVersion, + /// Server-resolved authorization domain of the operation. + pub authorization_domain: CommunityId, + /// Transport carrying the protected operation. + pub transport: AuthTransport, + /// Authenticated actor performing the operation. + pub actor_pubkey: PublicKey, + /// Stable identifier of the current active binding. + pub binding_id: Uuid, + /// Current active binding version. + pub binding_version: BindingVersion, + /// Exact server-resolved provider profile currently selected for the operation. + pub profile_id: AuthorizationProfileId, + /// Current provider policy version. + pub policy_version: PolicyVersion, + /// Exact capability required by the operation. + pub capability: AuthorizationCapability, +} + +impl fmt::Debug for LeaseUseRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LeaseUseRequirement") + .field("context_version", &self.context_version) + .field("lease_version", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport) + .field("actor_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("capability", &"[redacted]") + .finish() + } +} + +/// Central validator for protected lease use. +#[derive(Clone)] +pub struct AuthorizationLeaseValidator { + clock: SharedAuthorizationClock, +} + +impl AuthorizationLeaseValidator { + /// Create a validator that always reads from the supplied central clock. + pub fn new(clock: SharedAuthorizationClock) -> Self { + Self { clock } + } + + /// Validate every typed version, subject, transport, capability, and time bound. + pub fn authorize( + &self, + lease: &AuthorizationLease, + requirement: &LeaseUseRequirement, + ) -> Result<(), LeaseValidationError> { + if lease.context_version != requirement.context_version { + return Err(LeaseValidationError::ContextVersionMismatch); + } + if lease.lease_version != requirement.lease_version { + return Err(LeaseValidationError::LeaseVersionMismatch); + } + if lease.authorization_domain != requirement.authorization_domain { + return Err(LeaseValidationError::AuthorizationDomainMismatch); + } + if lease.transport != requirement.transport { + return Err(LeaseValidationError::TransportMismatch); + } + if lease.actor_pubkey != requirement.actor_pubkey { + return Err(LeaseValidationError::ActorMismatch); + } + if lease.binding_id != requirement.binding_id { + return Err(LeaseValidationError::BindingIdMismatch); + } + if lease.binding_version != requirement.binding_version { + return Err(LeaseValidationError::BindingVersionMismatch); + } + if lease.profile_id != requirement.profile_id { + return Err(LeaseValidationError::AuthorizationProfileMismatch); + } + if lease.policy_version != requirement.policy_version { + return Err(LeaseValidationError::PolicyVersionMismatch); + } + if !lease.capabilities.contains(requirement.capability) { + return Err(LeaseValidationError::MissingCapability); + } + let now = self.clock.now()?; + if now.unix_seconds() < lease.issued_at { + return Err(LeaseValidationError::ClockMovedBeforeIssue); + } + if now.unix_seconds() >= lease.expires_at { + return Err(LeaseValidationError::Expired); + } + Ok(()) + } + + /// Validate and retain a per-capability operation guard. + /// + /// Long-running streams and mutations retain this guard and call + /// [`AuthorizationOperationGuard::revalidate`] immediately before each + /// protected emission or commit. The guard borrows an access lease and + /// cannot be constructed from verification-only status. + pub fn operation_guard<'a>( + &self, + lease: &'a AuthorizationLease, + requirement: LeaseUseRequirement, + ) -> Result, LeaseValidationError> { + self.authorize(lease, &requirement)?; + Ok(AuthorizationOperationGuard { + lease, + validator: self.clone(), + requirement, + }) + } + + /// Return the deterministic renewal action for WebSocket or audio authority. + pub fn renewal_action( + &self, + schedule: LeaseRenewalSchedule, + ) -> Result { + let now = self.clock.now()?; + if now.unix_seconds() >= schedule.expires_at { + return Ok(LeaseRenewalAction::Expired); + } + if now.unix_seconds() >= schedule.renew_at { + return Ok(LeaseRenewalAction::RenewNow); + } + Ok(LeaseRenewalAction::Current) + } + + /// Remaining whole seconds until a trusted authorization deadline. + /// + /// Long-lived transports use the same injected clock as lease validation; + /// they must not schedule expiry from an independently read wall clock. + pub fn seconds_until(&self, expires_at: u64) -> Result { + let now = self.clock.now()?; + Ok(expires_at.saturating_sub(now.unix_seconds())) + } +} + +impl fmt::Debug for AuthorizationLeaseValidator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationLeaseValidator") + .field("clock", &"[injected]") + .finish() + } +} + +/// Retained per-capability authorization for one in-flight operation. +/// +/// Initial validation is not a substitute for the mandatory pre-commit or +/// pre-emission revalidation hook. Expiry and current typed versions are +/// checked again every time [`Self::revalidate`] is called. +pub struct AuthorizationOperationGuard<'a> { + lease: &'a AuthorizationLease, + validator: AuthorizationLeaseValidator, + requirement: LeaseUseRequirement, +} + +impl AuthorizationOperationGuard<'_> { + /// Revalidate the retained authority immediately before a protected effect. + pub fn revalidate(&self) -> Result<(), LeaseValidationError> { + self.validator.authorize(self.lease, &self.requirement) + } + + /// Exact capability retained by this guard. + pub const fn capability(&self) -> AuthorizationCapability { + self.requirement.capability + } + + /// Hard conservative expiry after which revalidation always denies. + pub const fn expires_at(&self) -> u64 { + self.lease.expires_at + } +} + +impl fmt::Debug for AuthorizationOperationGuard<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationOperationGuard") + .field("lease", &"[borrowed]") + .field("validator", &self.validator) + .field("requirement", &self.requirement) + .finish() + } +} + +/// Bounded lead time for renewing long-lived authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaseRenewalLeadTime(u64); + +impl LeaseRenewalLeadTime { + /// Build a non-zero renewal lead no greater than one hour. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds == 0 { + return Err(LeasePolicyError::ZeroRenewalLead); + } + if seconds > MAX_LEASE_RENEWAL_LEAD_SECONDS { + return Err(LeasePolicyError::RenewalLeadTooLarge); + } + Ok(Self(seconds)) + } + + /// Renewal lead in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Deterministic renewal and fail-closed expiry deadlines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaseRenewalSchedule { + renew_at: u64, + expires_at: u64, +} + +impl LeaseRenewalSchedule { + /// Time at which a long-lived transport should begin renewal. + pub const fn renew_at(self) -> u64 { + self.renew_at + } + + /// Hard deadline at which the transport must stop using authority. + pub const fn expires_at(self) -> u64 { + self.expires_at + } +} + +/// Current hook result for a long-lived transport's renewal loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseRenewalAction { + /// Existing authority remains current before its renewal window. + Current, + /// Renewal is due; failure to renew before expiry must close fail-closed. + RenewNow, + /// The hard deadline passed; authority must no longer be used. + Expired, +} + +/// Failure to issue an authorization lease or bounded display status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseIssueError { + /// A lease version was zero. + #[error("authorization lease version must be greater than zero")] + InvalidLeaseVersion, + /// A binding freshness bound was zero. + #[error("binding freshness bound must be greater than zero")] + InvalidBindingBound, + /// Binding freshness evidence named another binding or version. + #[error("binding freshness bound does not match the active binding")] + BindingBoundMismatch, + /// Computing the configured application bound overflowed Unix seconds. + #[error("application authorization bound overflowed")] + ApplicationBoundOverflow, + /// At least one required evidence bound was already expired or inside skew. + #[error("authorization evidence is no longer current")] + EvidenceExpired, +} + +impl LeaseIssueError { + /// Stable audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::InvalidLeaseVersion => "authorization_lease_issue_001", + Self::InvalidBindingBound => "authorization_lease_issue_002", + Self::BindingBoundMismatch => "authorization_lease_issue_003", + Self::ApplicationBoundOverflow => "authorization_lease_issue_004", + Self::EvidenceExpired => "authorization_lease_issue_005", + } + } +} + +/// Fail-closed protected-operation lease validation error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseValidationError { + /// The central authorization clock failed. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// The context did not carry a federated access lease. + #[error("authorization context does not carry an access lease")] + MissingLease, + /// The authorization-context contract version was not exact. + #[error("authorization context version does not match")] + ContextVersionMismatch, + /// The current session lease version was not exact. + #[error("authorization lease version does not match")] + LeaseVersionMismatch, + /// The operation belonged to another authorization domain. + #[error("authorization lease domain does not match")] + AuthorizationDomainMismatch, + /// The operation used another transport. + #[error("authorization lease transport does not match")] + TransportMismatch, + /// The operation was performed by another actor. + #[error("authorization lease actor does not match")] + ActorMismatch, + /// Current binding state identifies another stable binding. + #[error("authorization lease binding identifier does not match")] + BindingIdMismatch, + /// Current binding state has another version. + #[error("authorization lease binding version does not match")] + BindingVersionMismatch, + /// Current server policy selected another provider profile. + #[error("authorization lease provider profile does not match")] + AuthorizationProfileMismatch, + /// Current provider policy has another version. + #[error("authorization lease policy version does not match")] + PolicyVersionMismatch, + /// The lease did not grant the operation's exact capability. + #[error("authorization lease does not include the required capability")] + MissingCapability, + /// Central time moved before the lease issue time. + #[error("authorization clock moved before lease issue time")] + ClockMovedBeforeIssue, + /// The lease reached its conservative earliest expiry. + #[error("authorization lease has expired")] + Expired, +} + +impl LeaseValidationError { + /// Stable audit and metric code. + pub const fn code(&self) -> &'static str { + match self { + Self::Clock(_) => "authorization_lease_use_001", + Self::MissingLease => "authorization_lease_use_002", + Self::ContextVersionMismatch => "authorization_lease_use_003", + Self::LeaseVersionMismatch => "authorization_lease_use_004", + Self::AuthorizationDomainMismatch => "authorization_lease_use_005", + Self::TransportMismatch => "authorization_lease_use_006", + Self::ActorMismatch => "authorization_lease_use_007", + Self::BindingIdMismatch => "authorization_lease_use_013", + Self::BindingVersionMismatch => "authorization_lease_use_008", + Self::AuthorizationProfileMismatch => "authorization_lease_use_014", + Self::PolicyVersionMismatch => "authorization_lease_use_009", + Self::MissingCapability => "authorization_lease_use_010", + Self::ClockMovedBeforeIssue => "authorization_lease_use_011", + Self::Expired => "authorization_lease_use_012", + } + } +} + +pub(crate) fn conservative_expiry( + now: AuthorizationTime, + provider_effective_until: u64, + binding_valid_until: u64, + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +) -> Result { + let application_until = now + .unix_seconds() + .checked_add(application_limit.seconds()) + .ok_or(LeaseIssueError::ApplicationBoundOverflow)?; + let earliest = provider_effective_until + .min(binding_valid_until) + .min(application_until); + let conservative = earliest.saturating_sub(clock_skew.seconds()); + if conservative <= now.unix_seconds() { + return Err(LeaseIssueError::EvidenceExpired); + } + Ok(conservative) +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 1699555831..db12a82428 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -21,6 +21,10 @@ pub mod access; pub mod context; /// Authentication error types. pub mod error; +/// Trusted-workspace adapter for sealed verifier and binding evidence. +pub mod evidence_adapter; +/// Bounded, versioned authorization leases. +pub mod lease; /// NIP-42 challenge–response authentication. pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). @@ -43,11 +47,25 @@ pub use context::{ BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, - FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, - VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, + FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, + ProviderEvidenceValidationError, ResolvedFederatedPolicy, VerifiedFederatedAssertion, + VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, }; pub use error::AuthError; +pub use evidence_adapter::{ + ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput, + VerifiedEvidenceAdapter, +}; +pub use lease::{ + AccessLeasePolicy, ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockError, + AuthorizationClockSkew, AuthorizationLease, AuthorizationLeaseValidator, + AuthorizationOperationGuard, AuthorizationTime, BindingLeaseBound, LeaseIssueError, + LeasePolicyError, LeaseRenewalAction, LeaseRenewalLeadTime, LeaseRenewalSchedule, + LeaseUseRequirement, LeaseValidationError, LeaseVersion, SharedAuthorizationClock, + SystemAuthorizationClock, VerificationStatusPolicy, MAX_APPLICATION_LEASE_SECONDS, + MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS, MAX_LEASE_RENEWAL_LEAD_SECONDS, +}; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use nip98_replay::{ @@ -55,7 +73,8 @@ pub use nip98_replay::{ MAX_REPLAY_TTL_SECS, }; pub use provider::{ - AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial, + AuthorizationAuthority, AuthorizationCapability, + AuthorizationClock as ProviderAuthorizationClock, AuthorizationDenial, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 7b3e032962..ce65bc1636 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -91,6 +91,11 @@ impl CapabilitySet { .iter() .all(|capability| self.0.binary_search(capability).is_ok()) } + + /// Return whether the set includes one exact portable capability. + pub fn contains(&self, capability: AuthorizationCapability) -> bool { + self.0.binary_search(&capability).is_ok() + } } impl fmt::Debug for CapabilitySet { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5a8f24df89..15ad91119d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -59,6 +59,7 @@ async fn enforce_http_admission( /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +#[cfg(test)] pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, @@ -127,6 +128,99 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +/// Verify tenant bridge authentication and retain sealed proof evidence. +/// +/// The development-only `X-Pubkey` path returns no proof and is therefore +/// rejected later if this exact domain is configured for enforcement. +type ProtectedBridgeAuth = ( + nostr::PublicKey, + [u8; 32], + Option, +); + +pub(crate) fn verify_protected_bridge_auth( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, + require_payload: bool, + authorization_domain: buzz_core::CommunityId, +) -> Result)> { + let (pubkey, event_id) = verify_bridge_auth_with_options( + headers, + method, + url, + body, + require_auth_token, + require_payload, + )?; + let Some(encoded) = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Nostr ")) + else { + return Ok((pubkey, event_id, None)); + }; + use base64::Engine as _; + let event_json = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid Nostr auth"))?; + let proof = buzz_auth::VerifiedEvidenceAdapter::new() + .verify_nip98( + authorization_domain, + buzz_auth::AuthTransport::HttpBridge, + &event_json, + url, + method, + body, + None, + ) + .map_err(|error| { + api_error( + StatusCode::UNAUTHORIZED, + &format!("NIP-98 evidence: {error}"), + ) + })?; + if proof.actor_pubkey() != pubkey { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98 evidence actor mismatch", + )); + } + Ok((pubkey, event_id, Some(proof))) +} + +pub(crate) fn retain_bridge_proof( + proof: Option, + auth_tag: Option<&str>, +) -> Result>, (StatusCode, Json)> { + let Some(proof) = proof else { + return Ok(None); + }; + let actor = proof.actor_pubkey(); + let proof = match crate::corporate_identity::verify_unconditional_nip_oa_owner(actor, auth_tag) + { + Some(owner) => buzz_auth::VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + proof, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + owner, actor, None, true, + ), + ) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "NIP-98 delegation evidence mismatch", + ) + })?, + None => proof, + }; + Ok(Some(Arc::new(proof))) +} + /// Corporate identity enrollment must always start from cryptographic proof of /// the Nostr key. The development-only `X-Pubkey` fallback is caller-controlled /// and therefore cannot safely participate in a durable identity binding. @@ -188,28 +282,78 @@ async fn verify_bridge_corporate_identity( headers: &HeaderMap, pubkey: nostr::PublicKey, auth_tag: Option<&str>, -) -> Result)> { - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( +) -> Result, (StatusCode, Json)> { + let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( + state, + tenant.community(), headers, - &state.config.corporate_identity, - ); - crate::corporate_identity::verify_corporate_identity( + ) + .map_err(crate::corporate_identity::CorporateIdentityError::into_api_error)?; + match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await - .map_err(|e| e.into_api_error()) + { + Ok(proof) => Ok(Some(proof)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational bridge identity verification unavailable"); + Ok(None) + } + Err(error) => Err(error.into_api_error()), + } +} + +fn seal_bridge_assertion( + state: &AppState, + tenant: &TenantContext, + proof: Option<&crate::corporate_identity::CorporateIdentityProof>, +) -> Result>, (StatusCode, Json)> { + let Some(proof) = proof else { + return Ok(None); + }; + match crate::corporate_identity::current_verified_assertion_for_proof( + state, + proof, + tenant.community(), + buzz_auth::AuthTransport::HttpBridge, + ) { + Ok(assertion) => Ok(assertion.map(Arc::new)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = %error, "observational bridge assertion sealing unavailable"); + Ok(None) + } + Err(error) => Err(error.into_api_error()), + } } async fn finalize_bridge_corporate_identity( state: &AppState, tenant: &TenantContext, pubkey: nostr::PublicKey, - proof: crate::corporate_identity::CorporateIdentityProof, + proof: Option, ) -> Result<(), (StatusCode, Json)> { + if crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(()); + } + let Some(proof) = proof else { + return Ok(()); + }; crate::corporate_identity::finalize_corporate_identity(state, tenant.community(), pubkey, proof) .await .map(|_| ()) @@ -449,6 +593,7 @@ async fn handle_channel_window_filter( filter: &nostr::Filter, accessible_channels: &[uuid::Uuid], events: &mut Vec, + release_channels: &mut std::collections::BTreeSet, ) -> Result<(), (StatusCode, Json)> { use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; @@ -461,6 +606,7 @@ async fn handle_channel_window_filter( if !accessible_channels.contains(&ch_id) { return Ok(()); } + release_channels.insert(ch_id); // Composite request cursor: `until` + `before_id`, both or neither. The // window path has no timestamp-only fallback — that ambiguity is the @@ -678,7 +824,7 @@ pub async fn submit_event( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -687,20 +833,27 @@ pub async fn submit_event( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Everything after auth — admission, replay, membership, parse, ingest — // runs inside the helper. The thin wrapper here owns the single terminal // attribution line so it fires for every outcome, including admission/ // replay/membership failures that previously returned before any log fired. - let outcome = - submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let outcome = submit_event_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &outcome { SubmitOutcome::Ok { accepted, .. } => { tracing::info!( - pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, @@ -714,7 +867,6 @@ pub async fn submit_event( .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -726,7 +878,6 @@ pub async fn submit_event( } SubmitOutcome::Rejected { kind, reason, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -737,7 +888,6 @@ pub async fn submit_event( } SubmitOutcome::Err { status, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = status.as_u16(), accepted = false, @@ -802,6 +952,7 @@ async fn submit_event_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. @@ -877,6 +1028,59 @@ async fn submit_event_authed( }; } }; + let verified_proof = match retain_bridge_proof(verified_proof, auth_tag) { + Ok(proof) => proof, + Err(response) => { + return SubmitOutcome::Err { + status: response.0, + response, + } + } + }; + let verified_assertion = match seal_bridge_assertion(state, tenant, identity_proof.as_ref()) { + Ok(assertion) => assertion, + Err(response) => { + return SubmitOutcome::Err { + status: response.0, + response, + } + } + }; + let protected_result = match verified_proof.as_ref() { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + Arc::clone(proof), + verified_assertion.clone(), + crate::protected_surface::event_ingest_capability(buzz_core::kind::event_kind_u32( + &event, + )), + uuid::Uuid::new_v4(), + "http_events", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = match protected_result { + Ok(authority) => authority, + Err(error) => { + tracing::warn!(error = %error, "http event protected authorization denied"); + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization denied"), + }; + } + }; + if protected.revalidate().is_err() { + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization expired"), + }; + } if let Err(e) = finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await { return SubmitOutcome::Err { @@ -884,13 +1088,25 @@ async fn submit_event_authed( response: e, }; } - if let Some(owner) = nip_oa_owner { + if let Some(owner) = nip_oa_owner.filter(|_| { + crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + }) { + if protected.revalidate().is_err() { + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization expired"), + }; + } super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } let kind_u32 = buzz_core::kind::event_kind_u32(&event); let auth = IngestAuth::Http { pubkey, + owner_pubkey: nip_oa_owner, + verified_proof, + verified_assertion, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, }; @@ -966,7 +1182,7 @@ pub async fn query_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -975,31 +1191,32 @@ pub async fn query_events( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Admission, replay, membership, and filter execution all run inside the // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = query_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &result { - Ok(Json(Value::Array(events))) => { - tracing::info!( - pubkey = %pubkey_hex, - route = "/query", - status = 200u16, - result_count = events.len(), - "HTTP bridge request" - ); + Ok(Json(Value::Array(_))) => { + tracing::info!(route = "/query", status = 200u16, "HTTP bridge request"); } Ok(_) => { - tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request"); + tracing::info!(route = "/query", status = 200u16, "HTTP bridge request"); } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, route = "/query", status = status.as_u16(), "HTTP bridge request" @@ -1019,6 +1236,7 @@ async fn query_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; @@ -1071,320 +1289,424 @@ async fn query_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "http_query", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; - if filters.iter().any(|f| f.search.is_some()) { - if has_mixed_search_filters(&filters) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "mixed search and non-search filters not supported", - )); + // Keep the complete post-authorization computation inside one release + // boundary. Every success and every backend-derived failure must pass the + // same final authority check before its response shape becomes observable. + let fetched = async { + if filters.iter().any(|f| f.search.is_some()) { + if has_mixed_search_filters(&filters) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "mixed search and non-search filters not supported", + )); + } + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + let result = handle_bridge_search( + state, + &raw_filters, + &filters, + &accessible_channels, + tenant, + &authed_pubkey_hex, + &pubkey_bytes, + ) + .await?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + return Ok(result); } - return handle_bridge_search( - state, - &raw_filters, - &filters, - &accessible_channels, - tenant, - &authed_pubkey_hex, - &pubkey_bytes, - ) - .await; - } - if let Some(presence_events) = synthesize_presence(state, tenant, &filters).await { - return Ok(Json(Value::Array(presence_events))); - } + if let Some(presence_events) = synthesize_presence(state, tenant, &filters).await { + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + return Ok(( + Json(Value::Array(presence_events)), + std::collections::BTreeSet::new(), + )); + } - let mut events: Vec = Vec::new(); - let mut handled: std::collections::HashSet = std::collections::HashSet::new(); + let mut events: Vec = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); + let mut handled: std::collections::HashSet = std::collections::HashSet::new(); - // Channel-window filters (`top_level: true`) — the GUI read-model surface. - // Dispatched first: a window filter is never a feed/thread/catchall query. - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if !extension_flag(raw, "top_level") { - continue; + // Channel-window filters (`top_level: true`) — the GUI read-model surface. + // Dispatched first: a window filter is never a feed/thread/catchall query. + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if !extension_flag(raw, "top_level") { + continue; + } + handle_channel_window_filter( + state, + tenant, + raw, + filter, + &accessible_channels, + &mut events, + &mut release_channels, + ) + .await?; + handled.insert(idx); } - handle_channel_window_filter( - state, - tenant, - raw, - filter, - &accessible_channels, - &mut events, - ) - .await?; - handled.insert(idx); - } - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { + continue; + } + let feed_types = match extract_feed_types(raw) { + Some(t) => t, + None => continue, + }; + + let limit = filter + .limit + .map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT)) + .unwrap_or(20); + let since = filter + .since + .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); + + let mut seen_types = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + let mut feed_count = 0i64; + for feed_type in &feed_types { + let canonical = if feed_type == "agent_activity" { + "activity" + } else { + feed_type.as_str() + }; + if !seen_types.insert(canonical) { + continue; + } + if feed_count >= limit { + break; + } + let remaining = limit - feed_count; + let type_events = match canonical { + "mentions" => state + .db + .query_feed_mentions_routed( + "bridge_feed", + tenant.community(), + &pubkey_bytes, + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, + "needs_action" => state + .db + .query_feed_needs_action_routed( + "bridge_feed", + tenant.community(), + &pubkey_bytes, + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, + "activity" => state + .db + .query_feed_activity_routed( + "bridge_feed", + tenant.community(), + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, + _ => continue, + }; + for se in type_events { + if !seen.insert(se.event.id) { + continue; + } + if !event_in_accessible_channel(&se, &accessible_channels) { + continue; + } + // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 + // or kind:30622) to a non-owner via the feed path, even though feed SQL + // kind allowlists already exclude these kinds. + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } + if append_bridge_stored_event(&mut events, &mut release_channels, &se) { + feed_count += 1; + } + } + } + handled.insert(idx); } - let feed_types = match extract_feed_types(raw) { - Some(t) => t, - None => continue, - }; - let limit = filter - .limit - .map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT)) - .unwrap_or(20); - let since = filter - .since - .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); - - let mut seen_types = std::collections::HashSet::new(); - let mut seen = std::collections::HashSet::new(); - let mut feed_count = 0i64; - for feed_type in &feed_types { - let canonical = if feed_type == "agent_activity" { - "activity" - } else { - feed_type.as_str() - }; - if !seen_types.insert(canonical) { + let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { continue; } - if feed_count >= limit { - break; - } - let remaining = limit - feed_count; - let type_events = match canonical { - "mentions" => state - .db - .query_feed_mentions_routed( - "bridge_feed", - tenant.community(), - &pubkey_bytes, - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, - "needs_action" => state - .db - .query_feed_needs_action_routed( - "bridge_feed", - tenant.community(), - &pubkey_bytes, - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, - "activity" => state - .db - .query_feed_activity_routed( - "bridge_feed", - tenant.community(), - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, + let depth = match extract_depth_limit(raw) { + Some(d) => d, + None => continue, + }; + let e_values = match filter.generic_tags.get(&e_tag_key) { + Some(vs) if vs.len() == 1 => vs, + _ => continue, + }; + let root_hex = match e_values.iter().next() { + Some(h) => h, + None => continue, + }; + let root_bytes = match hex::decode(root_hex) { + Ok(b) if b.len() == 32 => b, _ => continue, }; - for se in type_events { - if !seen.insert(se.event.id) { + + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + handled.insert(idx); continue; } + } + + let limit = filter + .limit + .unwrap_or(100) + .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; + let thread_cursor = extract_thread_cursor(raw); + let thread_replies = state + .db + .get_thread_replies( + tenant.community(), + &root_bytes, + Some(depth), + limit, + thread_cursor.as_deref(), + ) + .await + .map_err(|e| internal_error(&format!("thread query error: {e}")))?; + + for reply in thread_replies { + let se = reply.stored_event; if !event_in_accessible_channel(&se, &accessible_channels) { continue; } // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the feed path, even though feed SQL - // kind allowlists already exclude these kinds. + // or kind:30622) to a non-owner via the thread path, even though + // requires_h_channel_scope already excludes these kinds from thread metadata. if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { continue; } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); - feed_count += 1; - } + append_bridge_stored_event(&mut events, &mut release_channels, &se); } + handled.insert(idx); } - handled.insert(idx); - } - let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; - } - let depth = match extract_depth_limit(raw) { - Some(d) => d, - None => continue, - }; - let e_values = match filter.generic_tags.get(&e_tag_key) { - Some(vs) if vs.len() == 1 => vs, - _ => continue, - }; - let root_hex = match e_values.iter().next() { - Some(h) => h, - None => continue, - }; - let root_bytes = match hex::decode(root_hex) { - Ok(b) if b.len() == 32 => b, - _ => continue, - }; - - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - handled.insert(idx); + // Phase 1 — pure construction + validation, in filter order. Access-scope + // skips and the `before_id` BAD_REQUEST are decided here, before any DB + // work is issued (validation errors are deterministic client mistakes, so + // surfacing them ahead of transient DB errors is strictly more predictable). + let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { continue; } - } - let limit = filter - .limit - .unwrap_or(100) - .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; - let thread_cursor = extract_thread_cursor(raw); - let thread_replies = state - .db - .get_thread_replies( - tenant.community(), - &root_bytes, - Some(depth), - limit, - thread_cursor.as_deref(), - ) - .await - .map_err(|e| internal_error(&format!("thread query error: {e}")))?; - - for reply in thread_replies { - let se = reply.stored_event; - if !event_in_accessible_channel(&se, &accessible_channels) { - continue; - } - // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the thread path, even though - // requires_h_channel_scope already excludes these kinds from thread metadata. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { - continue; - } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + continue; + } } - } - handled.insert(idx); - } - // Phase 1 — pure construction + validation, in filter order. Access-scope - // skips and the `before_id` BAD_REQUEST are decided here, before any DB - // work is issued (validation errors are deterministic client mistakes, so - // surfacing them ahead of transient DB errors is strictly more predictable). - let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; - } - - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - continue; + let mut query = crate::handlers::req::build_event_query_from_filter( + filter, + &pubkey_bytes, + state, + tenant.community(), + ) + .await; + crate::handlers::req::apply_access_scope_to_query( + &mut query, + extract_channel_from_filter(filter), + &accessible_channels, + ); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } - } - - let mut query = crate::handlers::req::build_event_query_from_filter( - filter, - &pubkey_bytes, - state, - tenant.community(), - ) - .await; - crate::handlers::req::apply_access_scope_to_query( - &mut query, - extract_channel_from_filter(filter), - &accessible_channels, - ); - // Shared-gated visibility pushdown: must mirror WS REQ so that a page of - // newer private events does not starve older shared ones off the page. - if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { - query.shared_gated_reader = Some(pubkey_bytes.clone()); - } - match extract_before_id(raw) { - BeforeId::Malformed => { - return Err(api_error( - StatusCode::BAD_REQUEST, - "before_id must be a 64-char hex event id", - )); - } - BeforeId::Valid(bid) => { - if query.until.is_none() { + match extract_before_id(raw) { + BeforeId::Malformed => { return Err(api_error( StatusCode::BAD_REQUEST, - "before_id requires until to be set", + "before_id must be a 64-char hex event id", )); } - query.before_id = Some(bid); + BeforeId::Valid(bid) => { + if query.until.is_none() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id requires until to be set", + )); + } + query.before_id = Some(bid); + } + BeforeId::Absent => {} } - BeforeId::Absent => {} - } - // Honor `page` on non-search general queries so offset paging works for - // the empty-query people directory (kind:0 listing). The FTS path - // (`handle_bridge_search`) has its own `page`/`per_page`; a filter with - // no `search` field lands here instead, where paging would otherwise be - // dropped and the directory would terminate at its first page. Deterministic - // ordering in `query_events` (`created_at DESC, id ASC`) makes offset paging - // stable. `page` defaults to 1 → offset 0, so unrelated general queries are - // unaffected. - if let Some(offset) = extract_page_offset(raw, query.limit) { - query.offset = Some(offset); - } + // Honor `page` on non-search general queries so offset paging works for + // the empty-query people directory (kind:0 listing). The FTS path + // (`handle_bridge_search`) has its own `page`/`per_page`; a filter with + // no `search` field lands here instead, where paging would otherwise be + // dropped and the directory would terminate at its first page. Deterministic + // ordering in `query_events` (`created_at DESC, id ASC`) makes offset paging + // stable. `page` defaults to 1 → offset 0, so unrelated general queries are + // unaffected. + if let Some(offset) = extract_page_offset(raw, query.limit) { + query.offset = Some(offset); + } - catchall_queries.push((idx, query)); - } + catchall_queries.push((idx, query)); + } - // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). - // Phase 3 consumes results in original filter order, so response ordering - // and error semantics match the previous serial loop. - use futures_util::stream::{self, StreamExt}; - let db = state.db.clone(); - let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { - let db = db.clone(); - async move { (idx, db.query_events_routed("bridge_query", &query).await) } - })) - .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); - - // Phase 3 — post-processing, strictly in filter order. - while let Some((idx, filter_events)) = catchall_results.next().await { - let filter = &filters[idx]; - match filter_events { - Ok(stored_events) => { - for se in stored_events { - if !event_in_accessible_channel(&se, &accessible_channels) { - continue; - } - if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) { - continue; - } - // Result-level read auth: never hand a viewer-private snapshot - // (kind:30622) to anyone but its owner, even via kindless `ids`. - // Also enforces author-only kinds (30300/30350) and the persona - // shared-gate (kind:30175 without ["shared","true"]). Single call - // covers all three gated event classes. - if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) { - continue; - } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); + // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). + // Phase 3 consumes results in original filter order, so response ordering + // and error semantics match the previous serial loop. + use futures_util::stream::{self, StreamExt}; + let db = state.db.clone(); + let mut catchall_results = + stream::iter(catchall_queries.into_iter().map(|(idx, query)| { + let db = db.clone(); + async move { (idx, db.query_events_routed("bridge_query", &query).await) } + })) + .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); + + // Phase 3 — post-processing, strictly in filter order. + while let Some((idx, filter_events)) = catchall_results.next().await { + let filter = &filters[idx]; + match filter_events { + Ok(stored_events) => { + for se in stored_events { + if !event_in_accessible_channel(&se, &accessible_channels) { + continue; + } + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) { + continue; + } + // Result-level read auth: never hand a viewer-private snapshot + // (kind:30622) to anyone but its owner, even via kindless `ids`. + // Also enforces author-only kinds (30300/30350) and the persona + // shared-gate (kind:30175 without ["shared","true"]). Single call + // covers all three gated event classes. + if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) + { + continue; + } + append_bridge_stored_event(&mut events, &mut release_channels, &se); } } - } - Err(e) => { - return Err(internal_error(&format!("query error: {e}"))); + Err(e) => { + return Err(internal_error(&format!("query error: {e}"))); + } } } + + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + Ok((Json(Value::Array(events)), release_channels)) } + .await; + let fetched = match fetched { + Ok((response, release_channels)) => { + revalidate_bridge_response_channels( + state, + tenant.community(), + &pubkey_bytes, + &release_channels, + ) + .await?; + Ok(response) + } + Err(error) => Err(error), + }; + release_protected_bridge_fetch(fetched, |fetched| protected.release_fetched(fetched))? +} - Ok(Json(Value::Array(events))) +/// Revalidate the authoritative stored channel ids retained alongside the +/// response without caches after all asynchronous fetch work. This is the HTTP +/// bridge's post-fetch/pre-emission channel fence. Event tags are intentionally +/// not consulted: a stored channel-scoped row remains fenced even if its signed +/// event omits or malforms an `h` tag. +async fn revalidate_bridge_response_channels( + state: &AppState, + community_id: buzz_core::tenant::CommunityId, + actor: &[u8], + channels: &std::collections::BTreeSet, +) -> Result<(), (StatusCode, Json)> { + for &channel_id in channels { + let allowed = state + .db + .channel_read_authorized(community_id, channel_id, actor) + .await + .map_err(|error| internal_error(&format!("channel release fence: {error}")))?; + if !allowed { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: channel access changed before response release", + )); + } + } + Ok(()) +} + +fn append_bridge_stored_event( + events: &mut Vec, + release_channels: &mut std::collections::BTreeSet, + stored: &buzz_core::StoredEvent, +) -> bool { + let Ok(value) = serde_json::to_value(&stored.event) else { + return false; + }; + if let Some(channel_id) = stored.channel_id { + release_channels.insert(channel_id); + } + events.push(value); + true } /// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`. @@ -1414,7 +1736,7 @@ pub async fn count_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -1423,29 +1745,29 @@ pub async fn count_events( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Admission, replay, membership, and count execution all run inside the // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = count_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &result { - Ok(Json(value)) => { - let count = value.get("count").and_then(Value::as_u64); - tracing::info!( - pubkey = %pubkey_hex, - route = "/count", - status = 200u16, - result_count = count, - "HTTP bridge request" - ); + Ok(Json(_)) => { + tracing::info!(route = "/count", status = 200u16, "HTTP bridge request"); } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, route = "/count", status = status.as_u16(), "HTTP bridge request" @@ -1465,6 +1787,7 @@ async fn count_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; @@ -1509,9 +1832,36 @@ async fn count_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "http_count", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = Arc::new( + protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?, + ); + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; let mut total: u64 = 0; + let mut release_channels = std::collections::BTreeSet::new(); for filter in &filters { let needs_author_only_filtering = crate::handlers::req::filter_can_match_author_only_kinds(filter); @@ -1535,6 +1885,7 @@ async fn count_events_authed( if !accessible_channels.contains(&ch_id) { continue; // Skip filters targeting inaccessible channels. } + release_channels.insert(ch_id); // Channel is accessible — count with pushability check. let mut query = crate::handlers::req::build_event_query_from_filter( filter, @@ -1559,7 +1910,10 @@ async fn count_events_authed( && !needs_result_gated_filtering && !needs_shared_gate_filtering { - match state.db.count_events_routed("bridge_count", &query).await { + let fetched = state.db.count_events_routed("bridge_count", &query).await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1569,11 +1923,13 @@ async fn count_events_authed( // Fallback: query + post-filter for non-pushable constraints. let mut q = query; crate::handlers::req::apply_count_fallback_limit(&mut q); - match state + let fetched = state .db .query_events_routed_bounded("bridge_count_fallback", &q) - .await - { + .await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1604,6 +1960,7 @@ async fn count_events_authed( } else { // No channel filter — use SQL-level channel_ids pushdown to count // only events in accessible channels (+ global events). + release_channels.extend(accessible_channels.iter().copied()); let mut query = crate::handlers::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -1630,7 +1987,10 @@ async fn count_events_authed( && !needs_shared_gate_filtering { query.limit = None; - match state.db.count_events_routed("bridge_count", &query).await { + let fetched = state.db.count_events_routed("bridge_count", &query).await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1639,11 +1999,13 @@ async fn count_events_authed( } else { // Fallback: query a bounded candidate set + post-filter. crate::handlers::req::apply_count_fallback_limit(&mut query); - match state + let fetched = state .db .query_events_routed_bounded("bridge_count_fallback", &query) - .await - { + .await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1674,6 +2036,20 @@ async fn count_events_authed( } } + if !crate::connection::release_channel_set_read_authority( + state.db.clone(), + tenant.community(), + release_channels.into_iter().collect(), + pubkey_bytes, + Some(protected), + ) + .await + { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: channel access changed before response release", + )); + } Ok(Json(serde_json::json!({ "count": total }))) } @@ -1723,7 +2099,7 @@ async fn handle_bridge_search( tenant: &buzz_core::tenant::TenantContext, reader_pubkey_hex: &str, pubkey_bytes: &[u8], -) -> Result, (StatusCode, Json)> { +) -> Result<(Json, std::collections::BTreeSet), (StatusCode, Json)> { // Bridge always includes global (channel-less) events — same as WS with // full scopes. `None` means no accessible channels and no global access → // empty result set (the caller short-circuits exactly as the WS door EOSEs). @@ -1732,10 +2108,16 @@ async fn handle_bridge_search( true, // include_global ) { Some(scope) => scope, - None => return Ok(Json(Value::Array(Vec::new()))), + None => { + return Ok(( + Json(Value::Array(Vec::new())), + std::collections::BTreeSet::new(), + )); + } }; let mut events: Vec = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new(); for (raw, filter) in raw_filters.iter().zip(filters) { @@ -1848,13 +2230,11 @@ async fn handle_bridge_search( if !seen_ids.insert(*id_array) { continue; } - if let Ok(v) = serde_json::to_value(&stored.event) { - events.push(v); - } + append_bridge_stored_event(&mut events, &mut release_channels, stored); } } - Ok(Json(Value::Array(events))) + Ok((Json(Value::Array(events)), release_channels)) } /// Query parameters for the webhook trigger endpoint. @@ -1894,6 +2274,19 @@ pub async fn workflow_webhook( .map_err(|_| not_found("workflow not found"))?; let community_id = tenant.community(); + // A webhook secret authenticates the trigger but carries no protected + // authority into the workflow run or its delayed actions. Enforce must + // therefore stop before even the run row exists until a transaction-owning + // workflow executor can validate authority at every durable commit. + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .map_err(|_| not_found("workflow not found"))?; + let workflow = state .db .get_workflow(community_id, id) @@ -2078,12 +2471,43 @@ async fn synthesize_presence( all_pubkeys.dedup(); // Look up Redis. - let presence_map = state + let stored_presence = state .pubsub .get_presence_bulk(tenant, &all_pubkeys) .await .unwrap_or_default(); + let verifier = crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::new( + state.db.clone(), + state.relay_keypair.secret_key().as_secret_bytes(), + ); + let mut presence_map = std::collections::HashMap::new(); + for (pubkey_hex, stored_status) in stored_presence { + match crate::authorization_runtime::ephemeral::decode_presence(&stored_status) { + Ok(Some(protected)) => { + let Ok(pubkey) = nostr::PublicKey::from_hex(&pubkey_hex) else { + continue; + }; + if verifier + .verify_actor_context( + tenant.community(), + protected.context_id, + pubkey.to_bytes(), + &protected.authority, + ) + .await + .is_ok() + { + presence_map.insert(pubkey_hex, protected.status); + } + } + Ok(None) if !state.is_protected_enforcing(tenant.community()) => { + presence_map.insert(pubkey_hex, stored_status); + } + Ok(None) | Err(_) => {} + } + } + if presence_map.is_empty() { return Some(Vec::new()); } @@ -2139,7 +2563,7 @@ async fn authorize_moderation_read( headers: &HeaderMap, path: &str, raw_query: Option<&str>, -) -> Result)> { +) -> Result)> { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) @@ -2158,7 +2582,7 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( headers, "GET", &url, @@ -2167,6 +2591,8 @@ async fn authorize_moderation_read( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2190,14 +2616,91 @@ async fn authorize_moderation_read( "restricted: moderator access required", ) })?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, &tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::Moderate, + uuid::Uuid::new_v4(), + "http_moderation_read", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, &tenant, pubkey, identity_proof).await?; - Ok(tenant) + Ok(ModerationReadAuthorization { + tenant, + protected, + actor_pubkey: pubkey_bytes, + }) +} + +/// Retains exact protected authority through the moderation fetch boundary. +struct ModerationReadAuthorization { + tenant: TenantContext, + protected: crate::authorization_runtime::transport::ProtectedAuthorization, + actor_pubkey: Vec, +} + +/// Revalidate both provider-neutral protected authority and the local +/// moderation role after the fetch transaction releases its locks and before +/// the response is returned to the transport. +async fn release_moderation_read( + state: &Arc, + authorization: &ModerationReadAuthorization, +) -> Result<(), (StatusCode, Json)> { + authorization + .protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + crate::handlers::moderation_authz::authorize_moderation_action( + &authorization.tenant, + state, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map(|_| ()) + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access changed before response release", + ) + }) } /// Cap on rows returned by a single moderation read. const MODERATION_READ_LIMIT: i64 = 500; +/// Releases a bridge fetch result only after the retained authority check. +/// +/// Keeping the complete `Result` inside the release boundary ensures that +/// authority loss takes precedence over both successful rows and backend +/// failures; neither response shape is observable after the lease is stale. +fn release_protected_bridge_fetch( + fetched: Result, + release: impl FnOnce(Result) -> Result, R>, +) -> Result, (StatusCode, Json)> { + release(fetched) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired")) +} + /// Optional `?status=` and `?limit=` query for moderation reads. #[derive(serde::Deserialize, Default)] pub struct ModerationReadQuery { @@ -2219,23 +2722,51 @@ pub async fn moderation_reports( RawQuery(raw_query): RawQuery, Query(q): Query, ) -> Result, (StatusCode, Json)> { - let tenant = authorize_moderation_read( + let authorization = authorize_moderation_read( &state, &headers, "/moderation/reports", raw_query.as_deref(), ) .await?; - let rows = state + let mut transaction = state .db - .list_moderation_reports( - tenant.community(), - q.status.as_deref(), - clamp_limit(q.limit), + .begin_transaction() + .await + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", ) + })?; + let fetched = buzz_db::moderation::list_reports_tx( + &mut transaction, + authorization.tenant.community(), + q.status.as_deref(), + clamp_limit(q.limit), + ) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list reports: {e}")))?; + let response = Json(Value::Array(rows.iter().map(report_json).collect())); + transaction + .commit() .await - .map_err(|e| internal_error(&format!("list reports: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } /// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). @@ -2245,15 +2776,46 @@ pub async fn moderation_audit( RawQuery(raw_query): RawQuery, Query(q): Query, ) -> Result, (StatusCode, Json)> { - let tenant = + let authorization = authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) .await?; - let rows = state + let mut transaction = state .db - .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) + .begin_transaction() + .await + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + let fetched = buzz_db::moderation::list_actions_tx( + &mut transaction, + authorization.tenant.community(), + clamp_limit(q.limit), + ) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list actions: {e}")))?; + let response = Json(Value::Array(rows.iter().map(action_json).collect())); + transaction + .commit() .await - .map_err(|e| internal_error(&format!("list actions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } /// `GET /moderation/restricted` — currently banned/timed-out members. @@ -2261,14 +2823,42 @@ pub async fn moderation_restricted( State(state): State>, headers: HeaderMap, ) -> Result, (StatusCode, Json)> { - let tenant = + let authorization = authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; - let rows = state + let mut transaction = state .db - .list_community_restrictions(tenant.community()) + .begin_transaction() .await - .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + let fetched = + buzz_db::moderation::list_restricted_tx(&mut transaction, authorization.tenant.community()) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; + let response = Json(Value::Array(rows.iter().map(ban_json).collect())); + transaction + .commit() + .await + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { @@ -2351,6 +2941,61 @@ mod tests { .to_bytes() } + #[test] + fn bridge_release_retains_stored_channel_without_trusting_event_tags() { + let event = EventBuilder::new(Kind::TextNote, "channel-scoped") + .sign_with_keys(&Keys::generate()) + .expect("sign event without h tag"); + let channel_id = uuid::Uuid::new_v4(); + let stored = buzz_core::StoredEvent::new(event, Some(channel_id)); + let mut events = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); + + assert!(append_bridge_stored_event( + &mut events, + &mut release_channels, + &stored, + )); + assert_eq!(events.len(), 1); + assert_eq!( + release_channels.into_iter().collect::>(), + [channel_id] + ); + } + + #[test] + fn observational_http_modes_use_read_only_identity_lane() { + use crate::authorization_runtime::{ + finalization::AuthorizationMode, + transport::{legacy_identity_lane_for_mode, LegacyIdentityLane}, + }; + + for mode in [AuthorizationMode::Shadow, AuthorizationMode::VerifyOnly] { + assert_eq!( + legacy_identity_lane_for_mode(Some(mode)), + LegacyIdentityLane::ObserveOnly + ); + } + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Enforce)), + LegacyIdentityLane::ProtectedEnforce + ); + } + + #[test] + fn protected_bridge_fetch_release_fences_success_and_backend_error_outcomes() { + let released = release_protected_bridge_fetch::(Ok(7), Ok) + .expect("current authority releases fetched rows") + .expect("successful fetch remains successful"); + assert_eq!(released, 7); + + for fetched in [Ok(7), Err("database unavailable")] { + let (status, _) = release_protected_bridge_fetch::(fetched, |_| Err(())) + .expect_err("authority loss must hide every fetched outcome"); + assert_eq!(status, StatusCode::FORBIDDEN); + } + } + #[test] fn corporate_identity_disables_x_pubkey_bridge_fallback() { let keys = Keys::generate(); @@ -3153,13 +3798,6 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } - /// Offsets are sized from the *clamped* limit the DB will honor, not from - /// what the client asked for. `filter_to_query_params` clamps an absent or - /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in - /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) - /// and that clamped value is what arrives here — so page N starts exactly - /// N-1 full pages in. Sizing from an unclamped limit would step past rows - /// the previous page never returned. #[test] fn extract_page_offset_sizes_pages_from_clamped_limit() { let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; @@ -3488,7 +4126,10 @@ mod tests { require_corporate_identity: bool, ) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + config.database_url = database_url.clone(); // Use the real local Redis so enforce_http_admission can pass. config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); @@ -3502,7 +4143,7 @@ mod tests { config.corporate_identity.audience = "buzz-relay".to_string(); } - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&database_url).await.ok()?; 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)) @@ -3871,8 +4512,8 @@ mod tests { "expected exactly 1 attribution line for invalid-JSON arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), - "attribution line must carry the pubkey;\nlog:\n{log}" + !log.contains(&pubkey_hex[..16]), + "runtime log exposed pubkey;\nlog:\n{log}" ); } @@ -3931,8 +4572,8 @@ mod tests { "expected exactly 1 attribution line for IngestError::Rejected arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), - "attribution line must carry the pubkey;\nlog:\n{log}" + !log.contains(&pubkey_hex[..16]), + "runtime log exposed pubkey;\nlog:\n{log}" ); } } diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs new file mode 100644 index 0000000000..d497b4fb42 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -0,0 +1,1420 @@ +//! Provider-neutral protected-transport authorization. +//! +//! Transport handlers request one portable capability at the point of use. +//! An injected resolver owns provider evaluation, binding/admission ordering, +//! and finalization. This module accepts only a finalized access context as +//! authority, binds it back to the exact operation, and retains a guard for +//! mandatory pre-commit or pre-emission revalidation. + +use std::{collections::HashMap, fmt, sync::Arc}; + +use async_trait::async_trait; +use buzz_auth::{ + AuthContext, AuthTransport, AuthorizationCapability, AuthorizationLease, + AuthorizationLeaseValidator, AuthorizationProfileId, BindingVersion, LeaseUseRequirement, + LeaseValidationError, PolicyVersion, SharedAuthorizationClock, VerificationOnlyDisposition, + VerifiedFederatedAssertion, VerifiedNostrProof, +}; +use buzz_core::CommunityId; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::finalization::{AuthorizationMode, EnrollmentDisposition}; + +/// Compatibility policy for the inherited corporate-identity lane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyIdentityLane { + /// Runtime absent or explicitly Off: run the inherited verifier and + /// finalizer without changing pre-O4 behavior. + Legacy, + /// Shadow or VerifyOnly: cryptographic verification and read-only policy + /// evaluation are allowed, but identity state and public projections are + /// immutable. This lane can never enroll, reactivate, strengthen, update, + /// or retire a binding. + ObserveOnly, + /// Enforce: the protected resolver owns admission/finalization and legacy + /// projection must not run. + ProtectedEnforce, +} + +/// Resolve the centralized identity compatibility policy for one exact domain. +pub fn legacy_identity_lane( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> LegacyIdentityLane { + legacy_identity_lane_for_mode( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)), + ) +} + +/// Resolve compatibility behavior from an already selected exact-domain mode. +pub const fn legacy_identity_lane_for_mode(mode: Option) -> LegacyIdentityLane { + match mode { + None | Some(AuthorizationMode::Off) => LegacyIdentityLane::Legacy, + Some(AuthorizationMode::Shadow) | Some(AuthorizationMode::VerifyOnly) => { + LegacyIdentityLane::ObserveOnly + } + Some(AuthorizationMode::Enforce) => LegacyIdentityLane::ProtectedEnforce, + } +} + +/// Server-owned activation for one exact authorization domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DomainTransportPolicy { + authorization_domain: CommunityId, + mode: AuthorizationMode, +} + +impl DomainTransportPolicy { + /// Construct policy from immutable server configuration. + pub const fn from_server_configuration( + authorization_domain: CommunityId, + mode: AuthorizationMode, + ) -> Self { + Self { + authorization_domain, + mode, + } + } + + /// Exact configured domain. + pub const fn authorization_domain(self) -> CommunityId { + self.authorization_domain + } + + /// Exact configured activation mode. + pub const fn mode(self) -> AuthorizationMode { + self.mode + } +} + +/// Exact protected operation presented to the configured resolver. +#[derive(Clone)] +pub struct ProtectedOperationRequest { + verified_proof: Arc, + capability: AuthorizationCapability, + correlation_id: Uuid, + session_id: Option, + surface: &'static str, + cancellation: Option, + verified_assertion: Option>, + enrollment_assertion: Option>, +} + +impl ProtectedOperationRequest { + /// Build one exact operation after transport authentication. + pub fn new( + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + ) -> Result { + Self::new_with_cancellation( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + None, + None, + ) + } + + pub(crate) fn new_with_cancellation( + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_id: Option, + cancellation: Option, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProtectedTransportError::InvalidCorrelationId); + } + if session_id == Some(Uuid::nil()) { + return Err(ProtectedTransportError::InvalidSessionId); + } + if surface.is_empty() { + return Err(ProtectedTransportError::InvalidSurface); + } + if !crate::protected_surface::protected_operation_matches( + surface, + verified_proof.authorized_transport(), + capability, + ) { + return Err(ProtectedTransportError::SurfaceCapabilityMismatch); + } + Ok(Self { + verified_proof, + capability, + correlation_id, + session_id, + surface, + cancellation, + verified_assertion, + enrollment_assertion: None, + }) + } + + fn new_enrollment( + verified_proof: Arc, + assertion: Arc, + correlation_id: Uuid, + surface: &'static str, + ) -> Result { + let mut request = Self::new( + verified_proof, + Some(Arc::clone(&assertion)), + AuthorizationCapability::InviteClaim, + correlation_id, + surface, + )?; + request.enrollment_assertion = Some(assertion); + Ok(request) + } + + /// Exact server-resolved authorization domain. + pub fn authorization_domain(&self) -> CommunityId { + self.verified_proof.authorization_domain() + } + + /// Transport carrying this operation. + pub fn transport(&self) -> AuthTransport { + self.verified_proof.authorized_transport() + } + + /// Authenticated actor. + pub fn actor_pubkey(&self) -> nostr::PublicKey { + self.verified_proof.actor_pubkey() + } + + /// Verified Nostr owner for delegated authority, when present. + pub fn owner_pubkey(&self) -> Option { + self.verified_proof + .verified_delegation() + .map(buzz_auth::VerifiedTransportDelegation::owner_pubkey) + } + + /// Sealed verifier evidence for this exact transport request or session. + pub fn verified_proof(&self) -> &Arc { + &self.verified_proof + } + + /// Direct verified assertion retained only for first-enrollment resolution. + pub fn enrollment_assertion(&self) -> Option<&Arc> { + self.enrollment_assertion.as_ref() + } + + /// Current direct assertion verified by the transport identity adapter. + pub fn verified_assertion(&self) -> Option<&Arc> { + self.verified_assertion.as_ref() + } + + /// Exact dynamically selected capability. + pub const fn capability(&self) -> AuthorizationCapability { + self.capability + } + + /// Correlation identifier for this decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable server-owned session identity for long-lived transports. + pub const fn session_id(&self) -> Option { + self.session_id + } + + /// Stable low-cardinality surface name for resolver telemetry. + pub const fn surface(&self) -> &'static str { + self.surface + } + + /// Server-owned cancellation for a leased session, when applicable. + pub fn cancellation(&self) -> Option { + self.cancellation.clone() + } +} + +impl fmt::Debug for ProtectedOperationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedOperationRequest") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport()) + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("capability", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("surface", &self.surface) + .finish() + } +} + +/// Resolver failure that carries only a stable, non-sensitive code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("protected authorization resolver denied the operation ({code})")] +pub struct ProtectedResolutionError { + code: &'static str, +} + +impl ProtectedResolutionError { + /// Construct a sanitized resolver denial. + pub const fn new(code: &'static str) -> Self { + Self { code } + } + + /// Stable denial code suitable for metrics and protocol mapping. + pub const fn code(self) -> &'static str { + self.code + } +} + +/// Exact-domain resolver implemented by the authorization adapter. +/// +/// Direct and delegated actors intentionally enter through the same method. +/// The request's verified owner is evidence, never a separate bypass path. +#[async_trait] +pub trait ProtectedAuthorizationResolver: Send + Sync { + /// Resolve and fully finalize current authority for one operation. + async fn resolve( + &self, + request: &ProtectedOperationRequest, + ) -> Result; + + /// Run a read-only observation without producing authority or mutating + /// identity, binding, membership, projection, or lease state. + async fn observe( + &self, + _request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + Err(ProtectedResolutionError::new( + "observational_provider_unavailable", + )) + } + + /// Resolve display-only current binding status without granting access or + /// mutating binding, membership, projection, or lease state. + async fn present( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + Err(ProtectedResolutionError::new( + "client_status_presentation_unavailable", + )) + } +} + +/// Display-only result retained with its exact invalidation observer. +pub struct ProtectedStatusResolution { + disposition: VerificationOnlyDisposition, + observer: Arc, + evaluation_generation: u64, +} + +impl ProtectedStatusResolution { + /// Couple one current status to the invalidation fence captured for it. + pub fn new( + disposition: VerificationOnlyDisposition, + observer: Arc, + evaluation_generation: u64, + ) -> Self { + Self { + disposition, + observer, + evaluation_generation, + } + } + + /// Consume the display result for dedicated delivery and reconciliation. + pub(crate) fn into_parts( + self, + ) -> ( + VerificationOnlyDisposition, + Arc, + u64, + ) { + (self.disposition, self.observer, self.evaluation_generation) + } +} + +impl fmt::Debug for ProtectedStatusResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedStatusResolution") + .field("disposition", &"[redacted]") + .field("observer", &"[registered]") + .field("evaluation_generation", &"[redacted]") + .finish() + } +} + +/// Finalized resolver output. +/// +/// Fields are private so enforcing access cannot be constructed without the +/// exact per-decision observer registered during finalization. +pub struct ProtectedResolution { + kind: ProtectedResolutionKind, +} + +enum ProtectedResolutionKind { + Access { + context: Box, + observer: Arc, + }, + VerificationOnly(VerificationOnlyDisposition), + Enrollment { + disposition: EnrollmentDisposition, + observer: Arc, + }, +} + +impl ProtectedResolution { + /// Couple finalized access with its exact read-only invalidation observer. + /// + /// Enforcing access has no observer-free constructor: + /// + /// ```compile_fail + /// use buzz_auth::AuthContext; + /// use buzz_relay::authorization_runtime::transport::ProtectedResolution; + /// + /// fn invalid(context: Box) -> ProtectedResolution { + /// ProtectedResolution::access(context) + /// } + /// ``` + pub fn access(context: Box, observer: Arc) -> Self { + Self { + kind: ProtectedResolutionKind::Access { context, observer }, + } + } + + /// Preserve a display-only result without manufacturing access state. + pub fn verification_only(disposition: VerificationOnlyDisposition) -> Self { + Self { + kind: ProtectedResolutionKind::VerificationOnly(disposition), + } + } + + /// Couple staged direct enrollment with its exact invalidation observer. + pub fn enrollment( + disposition: EnrollmentDisposition, + observer: Arc, + ) -> Self { + Self { + kind: ProtectedResolutionKind::Enrollment { + disposition, + observer, + }, + } + } +} + +impl fmt::Debug for ProtectedResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedResolution") + .field("kind", &"[redacted]") + .finish() + } +} + +/// Current trusted state for every dependency that can invalidate a lease. +#[derive(Clone, PartialEq, Eq)] +pub struct LeaseCurrentState { + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, +} + +impl LeaseCurrentState { + /// Construct state from the trusted finalization/invalidation runtime. + pub const fn from_trusted_runtime( + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + ) -> Self { + Self { + binding_version, + profile_id, + policy_version, + } + } +} + +impl fmt::Debug for LeaseCurrentState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LeaseCurrentState") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .finish() + } +} + +/// Exact per-decision observer used by a retained lease guard. +/// +/// Implementations retain the registered invalidation fence and session +/// dependencies for this exact finalization. They must check those dependencies +/// and read current binding/profile/policy state before returning. The observer +/// is intentionally read-only; transport adoption cannot publish invalidations. +pub trait LeaseCurrentStateObserver: Send + Sync { + /// Validate the registered fence/dependencies and return current versions. + fn observe_current(&self) -> Result; + + /// Return the captured durable generation and exact selector dependencies + /// required by a transaction-owned mutation commit. + /// + /// Read-only observers may retain the default fail-closed implementation. + fn observe_commit_fence( + &self, + ) -> Result { + Err(LeaseCurrentStateError::Unavailable) + } +} + +/// Fail-closed current-state result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseCurrentStateError { + /// Binding, profile, or policy state changed. + #[error("protected authorization state is stale")] + Stale, + /// Current state could not be read. + #[error("protected authorization state is unavailable")] + Unavailable, +} + +/// Optional protected-transport runtime. +/// +/// Domains absent from this map, and domains explicitly configured `Off`, use +/// legacy behavior. An enforcing domain never falls back after a resolver or +/// lease failure. +pub struct ProtectedTransportRuntime { + domains: HashMap, + resolver: Arc, + validator: AuthorizationLeaseValidator, +} + +impl ProtectedTransportRuntime { + /// Build an exact-domain runtime, rejecting duplicate configuration. + pub fn new( + policies: impl IntoIterator, + resolver: Arc, + clock: SharedAuthorizationClock, + ) -> Result { + let mut domains = HashMap::new(); + for policy in policies { + if domains + .insert(policy.authorization_domain, policy.mode) + .is_some() + { + return Err(ProtectedTransportError::AmbiguousDomainPolicy); + } + } + Ok(Self { + domains, + resolver, + validator: AuthorizationLeaseValidator::new(clock), + }) + } + + /// Return the exact configured mode, if this runtime owns the domain. + pub fn mode_for_domain(&self, domain: CommunityId) -> Option { + self.domains.get(&domain).copied() + } + + /// Exact domains whose background effects must remain unavailable. + pub fn enforcing_domains(&self) -> Vec { + self.domains + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() + } + + /// Resolve one protected operation or explicitly preserve legacy behavior. + pub async fn authorize( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let Some(mode) = self.mode_for_domain(request.authorization_domain()) else { + return Ok(ProtectedAuthorization::Legacy); + }; + match mode { + AuthorizationMode::Off => Ok(ProtectedAuthorization::Legacy), + // Transport adoption has no mutation-free decision-only resolver. + // Preserve legacy access in both observational modes and leave + // their status evaluation to the lane that owns that API. + AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { + // Observation failures are telemetry only. These modes must + // never alter the inherited access result. + let _ = self.resolver.observe(request).await; + Ok(ProtectedAuthorization::Legacy) + } + AuthorizationMode::Enforce => { + let resolution = self + .resolver + .resolve(request) + .await + .map_err(ProtectedTransportError::Resolution)?; + let (context, observer) = match resolution.kind { + ProtectedResolutionKind::Access { context, observer } => (context, observer), + ProtectedResolutionKind::VerificationOnly(_status) => { + return Err(ProtectedTransportError::VerificationOnlyCannotGrant) + } + ProtectedResolutionKind::Enrollment { .. } => { + return Err(ProtectedTransportError::EnrollmentCannotGrantAccess) + } + }; + let context: Arc = Arc::from(context); + let authority = ProtectedOperationAuthority { + context, + capability: request.capability(), + validator: self.validator.clone(), + observer, + }; + authority.validate_exact_request(request)?; + authority.revalidate()?; + Ok(ProtectedAuthorization::Access(authority)) + } + } + } + + /// Resolve a dedicated client presentation only in VerifyOnly or Enforce. + /// Off and Shadow remain byte-for-byte non-presenting legacy behavior. + pub async fn present_status( + &self, + request: &ProtectedOperationRequest, + ) -> Result, ProtectedTransportError> { + match self.mode_for_domain(request.authorization_domain()) { + Some(AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce) => self + .resolver + .present(request) + .await + .map(Some) + .map_err(ProtectedTransportError::Resolution), + None | Some(AuthorizationMode::Off | AuthorizationMode::Shadow) => Ok(None), + } + } + + /// Resolve direct first-enrollment evidence without requiring an active binding. + pub async fn authorize_enrollment( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let Some(mode) = self.mode_for_domain(request.authorization_domain()) else { + return Ok(ProtectedEnrollmentAuthorization::Legacy); + }; + match mode { + AuthorizationMode::Off | AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { + Ok(ProtectedEnrollmentAuthorization::Legacy) + } + AuthorizationMode::Enforce => { + if request.capability() != AuthorizationCapability::InviteClaim + || request.enrollment_assertion().is_none() + || request.owner_pubkey().is_some() + { + return Err(ProtectedTransportError::EnrollmentEvidenceRequired); + } + let resolution = self + .resolver + .resolve(request) + .await + .map_err(ProtectedTransportError::Resolution)?; + let (disposition, observer) = match resolution.kind { + ProtectedResolutionKind::Enrollment { + disposition, + observer, + } => (disposition, observer), + _ => return Err(ProtectedTransportError::EnrollmentEvidenceRequired), + }; + let authority = ProtectedEnrollmentAuthority { + disposition, + observer, + validator: self.validator.clone(), + }; + authority.validate_exact_request(request)?; + authority.revalidate()?; + Ok(ProtectedEnrollmentAuthorization::Enrollment(authority)) + } + } + } +} + +/// Consult the optional runtime for one authenticated relay operation. +/// +/// This is the common transport seam used by HTTP, WebSocket, Git, media, and +/// audio handlers. Runtime absence is the only implicit legacy case. +pub async fn authorize_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + )?; + runtime.authorize(&request).await +} + +/// Consult the runtime for a leased session and expose only its server-owned +/// cancellation token to the resolver's invalidation registration. +#[allow(clippy::too_many_arguments)] +pub async fn authorize_session_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_id: Uuid, + cancellation: CancellationToken, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new_with_cancellation( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + Some(session_id), + Some(cancellation), + )?; + let authority = runtime.authorize(&request).await?; + state + .conn_manager + .retain_protected_session_authority(session_id, &authority); + Ok(authority) +} + +/// Resolve staged direct authority for atomic invite enrollment. +pub async fn authorize_enrollment_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + assertion: Arc, + correlation_id: Uuid, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedEnrollmentAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new_enrollment( + verified_proof, + assertion, + correlation_id, + "invite.claim", + )?; + runtime.authorize_enrollment(&request).await +} + +/// Preserve legacy behavior only when an unwired surface cannot enter an +/// enforcing exact-domain runtime. +/// +/// This helper never constructs a resolver request from caller-supplied key +/// primitives. It exists solely to make missing typed evidence fail closed on +/// enforcing domains while retaining the configured Off/Shadow/VerifyOnly +/// behavior for legacy-only authentication paths. +pub fn authorize_unwired_if_configured( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> Result { + authorize_unwired_for_mode( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)), + ) +} + +/// Deny an unproved mutation in Enforce before any backend-visible work starts. +/// +/// Alternate helpers such as secret-triggered workflow execution have no +/// request proof that a protected resolver can finalize. Until a transaction- +/// owning executor carries durable authority into their commit, they must be +/// unavailable in Enforce while preserving every legacy lane. +pub fn require_unwired_atomic_mutation_if_configured( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> Result<(), ProtectedTransportError> { + authorize_unwired_if_configured(state, authorization_domain)?.require_atomic_mutation() +} + +fn authorize_unwired_for_mode( + mode: Option, +) -> Result { + match mode { + None + | Some(AuthorizationMode::Off) + | Some(AuthorizationMode::Shadow) + | Some(AuthorizationMode::VerifyOnly) => Ok(ProtectedAuthorization::Legacy), + Some(AuthorizationMode::Enforce) => Err(ProtectedTransportError::MissingVerifiedProof), + } +} + +impl fmt::Debug for ProtectedTransportRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedTransportRuntime") + .field("domains", &"[redacted]") + .field("resolver", &"[configured]") + .field("validator", &self.validator) + .finish() + } +} + +/// Result of consulting the direct first-enrollment runtime. +#[must_use] +pub enum ProtectedEnrollmentAuthorization { + /// Runtime absent or configured non-enforcing. + Legacy, + /// Enforcing staged enrollment with no binding or membership yet. + Enrollment(ProtectedEnrollmentAuthority), +} + +impl ProtectedEnrollmentAuthorization { + /// Seal the enrollment decision for transaction-owned execution. + pub fn seal_postgres_enrollment( + &self, + operation_id: super::executor::ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Enrollment(authority) => super::executor::SealedEnrollmentPermit::from_authority( + authority, + operation_id, + operation_kind, + request_fingerprint, + ) + .map(Some), + } + } + + /// Whether this decision is enforcing. + pub const fn is_enforcing(&self) -> bool { + matches!(self, Self::Enrollment(_)) + } +} + +/// Staged direct authority that cannot be consumed as ordinary access. +pub struct ProtectedEnrollmentAuthority { + disposition: EnrollmentDisposition, + observer: Arc, + validator: AuthorizationLeaseValidator, +} + +impl ProtectedEnrollmentAuthority { + pub(super) const fn disposition(&self) -> &EnrollmentDisposition { + &self.disposition + } + + pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { + self.observer.as_ref() + } + + fn validate_exact_request( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedTransportError> { + if self.disposition.authorization_domain() != request.authorization_domain() + || self.disposition.actor_pubkey() != request.actor_pubkey() + || self.disposition.correlation_id() != request.correlation_id() + || request.capability() != AuthorizationCapability::InviteClaim + { + return Err(ProtectedTransportError::FinalizedContextMismatch); + } + Ok(()) + } + + /// Recheck time and the captured invalidation fence before sealing. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + self.validator + .seconds_until(self.disposition.expires_at())?; + self.observer.observe_commit_fence()?; + Ok(()) + } +} + +impl fmt::Debug for ProtectedEnrollmentAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedEnrollmentAuthority([redacted])") + } +} + +/// Result of consulting the optional exact-domain runtime. +#[derive(Clone)] +#[must_use] +pub enum ProtectedAuthorization { + /// Runtime absent for this exact domain or configured non-enforcing. + Legacy, + /// Enforcing access with a retained operation guard. + Access(ProtectedOperationAuthority), +} + +impl ProtectedAuthorization { + /// Revalidate enforcing authority; legacy mode is a no-op. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + match self { + Self::Legacy => Ok(()), + Self::Access(authority) => authority.revalidate(), + } + } + + /// Release already-fetched protected data only after a final authority check. + /// + /// Callers place this at the last synchronous boundary before constructing + /// a response, queue entry, or stream item. Keeping the fetched value owned + /// by this method makes the post-fetch/pre-emission ordering explicit. + pub fn release_fetched(&self, value: T) -> Result { + self.revalidate()?; + Ok(value) + } + + /// Require an atomic mutation coordinator for an enforcing operation. + /// + /// Legacy lanes retain their exact behavior. Enforcing callers must select + /// the transaction/CAS executor for their registered surface; an adjacent + /// authorization check is never treated as an atomic fence. + pub fn require_atomic_mutation(&self) -> Result<(), ProtectedTransportError> { + require_atomic_mutation_executor(matches!(self, Self::Access(_))) + } + + /// Seal an enforcing mutation for transaction-owned PostgreSQL execution. + /// + /// Legacy lanes return `None` and retain their existing mutation path. + /// Enforcing lanes return a permit that cannot be constructed from request + /// fields or an adjacent authorization check. + pub fn seal_postgres_mutation( + &self, + operation_id: super::executor::ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => super::executor::SealedOperationPermit::from_authority( + authority, + operation_id, + operation_kind, + request_fingerprint, + ) + .map(Some), + } + } + + /// Seal opaque sender authority for one ephemeral event delivered across + /// the trusted relay Redis plane. The resulting claim still requires a + /// relay signature and writer-database revalidation on the receiving pod. + pub(crate) fn seal_ephemeral_delivery( + &self, + event_id: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => { + super::executor::EphemeralAuthorityClaim::from_authority(authority, event_id) + .map(Some) + } + } + } + + /// Whether this value carries enforcing authority rather than legacy mode. + pub const fn is_enforcing(&self) -> bool { + matches!(self, Self::Access(_)) + } + + /// Hard lease expiry for enforcing authority. + pub fn expires_at(&self) -> Option { + match self { + Self::Legacy => None, + Self::Access(authority) => Some(authority.expires_at()), + } + } + + /// Delay to hard expiry using the same injected clock as lease validation. + pub fn expiry_delay(&self) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => authority.expiry_delay().map(Some), + } + } +} + +fn require_atomic_mutation_executor(enforcing: bool) -> Result<(), ProtectedTransportError> { + if enforcing { + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + } else { + Ok(()) + } +} + +impl fmt::Debug for ProtectedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Legacy => formatter.write_str("ProtectedAuthorization::Legacy"), + Self::Access(_) => formatter.write_str("ProtectedAuthorization::Access([redacted])"), + } + } +} + +/// Retained authority for request, stream, and pre-commit checkpoints. +#[derive(Clone)] +pub struct ProtectedOperationAuthority { + context: Arc, + capability: AuthorizationCapability, + validator: AuthorizationLeaseValidator, + observer: Arc, +} + +impl ProtectedOperationAuthority { + pub(super) fn context(&self) -> &AuthContext { + &self.context + } + + pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { + self.observer.as_ref() + } + + fn lease(&self) -> Result<&AuthorizationLease, ProtectedTransportError> { + self.context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease) + } + + fn requirement( + &self, + current: &LeaseCurrentState, + ) -> Result { + let lease = self.lease()?; + Ok(LeaseUseRequirement { + context_version: lease.context_version(), + lease_version: lease.lease_version(), + authorization_domain: lease.authorization_domain(), + transport: lease.transport(), + actor_pubkey: lease.actor_pubkey(), + binding_id: lease.binding_id(), + binding_version: current.binding_version, + profile_id: current.profile_id.clone(), + policy_version: current.policy_version.clone(), + capability: self.capability, + }) + } + + fn validate_exact_request( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedTransportError> { + if self.context.tenant().community() != request.authorization_domain() + || self.context.transport() != request.transport() + || self.context.pubkey() != request.actor_pubkey() + || self.context.agent_owner_pubkey() != request.owner_pubkey() + || !exact_correlation_matches(self.context.correlation_id(), request.correlation_id()) + { + return Err(ProtectedTransportError::FinalizedContextMismatch); + } + Ok(()) + } + + /// Revalidate current state, exact capability, typed versions, and expiry. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + let current = self.observer.observe_current()?; + self.context + .authorize_lease_use(&self.validator, &self.requirement(¤t)?)?; + Ok(()) + } + + /// Exact operation capability retained by this authority. + pub const fn capability(&self) -> AuthorizationCapability { + self.capability + } + + /// Hard conservative expiry after which revalidation fails. + pub fn expires_at(&self) -> u64 { + self.context + .authorization_lease() + .map_or(0, AuthorizationLease::expires_at) + } + + fn expiry_delay(&self) -> Result { + let expires_at = self.expires_at(); + let seconds = self.validator.seconds_until(expires_at)?; + Ok(std::time::Duration::from_secs(seconds)) + } +} + +fn exact_correlation_matches(finalized: Uuid, requested: Uuid) -> bool { + finalized == requested +} + +impl fmt::Debug for ProtectedOperationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedOperationAuthority") + .field("context", &"[redacted]") + .field("capability", &"[redacted]") + .field("validator", &self.validator) + .field("observer", &"[configured]") + .finish() + } +} + +/// Fail-closed protected-transport error. +#[derive(Debug, Error)] +pub enum ProtectedTransportError { + /// Duplicate exact-domain activation is ambiguous. + #[error("protected authorization policy is ambiguous for this domain")] + AmbiguousDomainPolicy, + /// Correlation IDs must be non-nil. + #[error("protected authorization correlation id is invalid")] + InvalidCorrelationId, + /// Session IDs for long-lived transports must be non-nil. + #[error("protected authorization session id is invalid")] + InvalidSessionId, + /// Surface labels must be stable non-empty server constants. + #[error("protected authorization surface is invalid")] + InvalidSurface, + /// The surface, sealed proof transport, and capability do not match. + #[error("protected authorization surface does not match proof and capability")] + SurfaceCapabilityMismatch, + /// An enforcing surface did not retain sealed verifier evidence. + #[error("protected authorization requires verified transport evidence")] + MissingVerifiedProof, + /// Resolver denied or could not evaluate current policy. + #[error(transparent)] + Resolution(#[from] ProtectedResolutionError), + /// Display verification is deliberately not access authority. + #[error("verification-only authorization cannot grant protected access")] + VerificationOnlyCannotGrant, + /// A staged enrollment result was presented as ordinary access. + #[error("first-enrollment authorization cannot grant ordinary protected access")] + EnrollmentCannotGrantAccess, + /// Invite enrollment lacked matching direct assertion/provider evidence. + #[error("protected invite enrollment requires direct verified evidence")] + EnrollmentEvidenceRequired, + /// Resolver returned a context for another exact operation. + #[error("finalized authorization context does not match protected operation")] + FinalizedContextMismatch, + /// Enforcing disposition lacked its bounded lease. + #[error("enforcing authorization context is missing its access lease")] + MissingAccessLease, + /// Current binding/profile/policy observation failed closed. + #[error(transparent)] + CurrentState(#[from] LeaseCurrentStateError), + /// Typed lease validation failed. + #[error(transparent)] + Lease(#[from] LeaseValidationError), + /// No transaction- or CAS-coupled mutation coordinator owns this commit. + #[error("protected mutation requires an atomic authorization fence")] + AtomicMutationFenceUnavailable, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use buzz_auth::{ + AuthorizationClock, AuthorizationClockError, AuthorizationTime, VerifiedEvidenceAdapter, + }; + use nostr::{EventBuilder, Keys, RelayUrl}; + + use super::*; + + struct UnavailableResolver; + + #[async_trait] + impl ProtectedAuthorizationResolver for UnavailableResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + Err(ProtectedResolutionError::new("synthetic_unavailable")) + } + } + + struct PanicResolver; + + #[async_trait] + impl ProtectedAuthorizationResolver for PanicResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + panic!("non-enforcing mode must not call a finalizing resolver") + } + } + + struct ObservingResolver(AtomicUsize); + + #[async_trait] + impl ProtectedAuthorizationResolver for ObservingResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + panic!("observational mode must not finalize authority") + } + + async fn observe( + &self, + _request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(ProtectedResolutionError::new("synthetic_denial")) + } + } + + struct FixedClock; + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(100)) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn runtime( + policies: impl IntoIterator, + ) -> Result { + ProtectedTransportRuntime::new( + policies, + Arc::new(UnavailableResolver), + Arc::new(FixedClock), + ) + } + + fn request(domain: CommunityId) -> ProtectedOperationRequest { + let keys = Keys::generate(); + let challenge = "protected-request-test"; + let relay_url = "wss://relay.example"; + let event = EventBuilder::auth(challenge, RelayUrl::parse(relay_url).expect("relay URL")) + .sign_with_keys(&keys) + .expect("signed NIP-42 event"); + let proof = VerifiedEvidenceAdapter::new() + .verify_nip42( + domain, + AuthTransport::RelayWebSocket, + &event, + challenge, + relay_url, + None, + ) + .expect("verified NIP-42 proof"); + ProtectedOperationRequest::new( + Arc::new(proof), + None, + AuthorizationCapability::CommunityRead, + Uuid::new_v4(), + "ws_req", + ) + .expect("valid request") + } + + #[test] + fn request_retains_sealed_proof_and_rejects_surface_substitution() { + let request = request(domain(1)); + let retained = Arc::clone(request.verified_proof()); + assert!(Arc::ptr_eq(&retained, request.verified_proof())); + assert_eq!(request.authorization_domain(), domain(1)); + assert_eq!(request.transport(), AuthTransport::RelayWebSocket); + + assert!(matches!( + ProtectedOperationRequest::new( + retained, + None, + AuthorizationCapability::MediaWrite, + Uuid::new_v4(), + "media.upload", + ), + Err(ProtectedTransportError::SurfaceCapabilityMismatch) + )); + } + + #[test] + fn finalized_context_cannot_be_replayed_across_correlations() { + let finalized = Uuid::from_u128(1); + assert!(exact_correlation_matches(finalized, finalized)); + assert!(!exact_correlation_matches(finalized, Uuid::from_u128(2))); + } + + #[tokio::test] + async fn absent_and_off_domains_preserve_legacy_behavior() { + let configured = domain(1); + let absent = domain(2); + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Off, + )]) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + assert!(matches!( + runtime.authorize(&request(absent)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn enforcing_domain_never_falls_back_on_resolver_failure() { + let configured = domain(1); + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Enforce, + )]) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Err(ProtectedTransportError::Resolution(_)) + )); + } + + #[tokio::test] + async fn verify_only_preserves_legacy_access_without_finalizing() { + let configured = domain(1); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::VerifyOnly, + )], + Arc::new(PanicResolver), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn shadow_preserves_legacy_access_without_finalizing() { + let configured = domain(1); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Shadow, + )], + Arc::new(PanicResolver), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn observational_denial_is_read_only_and_never_changes_access() { + let configured = domain(1); + let resolver = Arc::new(ObservingResolver(AtomicUsize::new(0))); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Shadow, + )], + resolver.clone(), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + assert_eq!(resolver.0.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn client_status_is_absent_in_off_and_shadow_and_fail_closed_when_unavailable() { + let configured = domain(1); + for mode in [AuthorizationMode::Off, AuthorizationMode::Shadow] { + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, mode, + )]) + .expect("runtime"); + assert!(runtime + .present_status(&request(configured)) + .await + .expect("non-presenting mode") + .is_none()); + } + + for mode in [AuthorizationMode::VerifyOnly, AuthorizationMode::Enforce] { + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, mode, + )]) + .expect("runtime"); + assert!(matches!( + runtime.present_status(&request(configured)).await, + Err(ProtectedTransportError::Resolution(_)) + )); + } + } + + #[test] + fn duplicate_exact_domain_policy_is_rejected() { + let configured = domain(1); + assert!(matches!( + runtime([ + DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Off, + ), + DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Enforce, + ), + ]), + Err(ProtectedTransportError::AmbiguousDomainPolicy) + )); + } + + #[test] + fn atomic_mutations_are_unavailable_only_for_enforcing_authority() { + assert!(require_atomic_mutation_executor(false).is_ok()); + assert!(matches!( + require_atomic_mutation_executor(true), + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + )); + assert!(ProtectedAuthorization::Legacy + .require_atomic_mutation() + .is_ok()); + } + + #[test] + fn unwired_mutations_preserve_legacy_modes_and_deny_enforce() { + for mode in [ + None, + Some(AuthorizationMode::Off), + Some(AuthorizationMode::Shadow), + Some(AuthorizationMode::VerifyOnly), + ] { + assert!(matches!( + authorize_unwired_for_mode(mode), + Ok(ProtectedAuthorization::Legacy) + )); + } + assert!(matches!( + authorize_unwired_for_mode(Some(AuthorizationMode::Enforce)), + Err(ProtectedTransportError::MissingVerifiedProof) + )); + } +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e0b10a91d2..7f480cf3b8 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -119,6 +119,19 @@ impl Default for CorporateIdentityConfig { } } +impl CorporateIdentityConfig { + /// Whether a complete verifier is configured independently of the legacy + /// process-wide enforcement switch. + pub fn verifier_configured(&self) -> bool { + !self.jwt_header.is_empty() + && !self.jwks_uri.is_empty() + && !self.issuer.is_empty() + && !self.audience.is_empty() + && !self.uid_claim.is_empty() + && !self.display_claim.is_empty() + } +} + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -558,7 +571,11 @@ fn load_corporate_identity_config() -> Result Result) -> fmt::Result { + formatter + .debug_struct("CorporateJwtClaims") + .field("issuer", &"[redacted]") + .field("uid", &"[redacted]") + .field("display_name", &"[redacted]") + .field("public_display_name", &"[redacted]") + .field("pubkey", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +#[derive(Deserialize)] struct RawJwtClaims { #[serde(flatten)] claims: Map, } +impl fmt::Debug for RawJwtClaims { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RawJwtClaims") + .field("claims", &"[redacted]") + .finish() + } +} + /// Service that verifies corporate identity JWTs against configured JWKS. -#[derive(Debug)] pub struct CorporateIdentityService { config: CorporateIdentityConfig, http: Result, jwks: RwLock>, refresh: Mutex<()>, + authorization_clock: SharedAuthorizationClock, +} + +impl fmt::Debug for CorporateIdentityService { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CorporateIdentityService") + .field("config", &"[redacted]") + .field("http", &"[initialized]") + .field("jwks", &"[cache]") + .field("refresh", &"[lock]") + .field("authorization_clock", &"[injected]") + .finish() + } } impl CorporateIdentityService { /// Build a corporate identity verifier from relay config. pub fn new(config: CorporateIdentityConfig) -> Self { + Self::with_authorization_clock(config, Arc::new(SystemAuthorizationClock)) + } + + /// Build a verifier with the shared authorization clock. + pub fn with_authorization_clock( + config: CorporateIdentityConfig, + authorization_clock: SharedAuthorizationClock, + ) -> Self { let http = reqwest::Client::builder() .connect_timeout(JWKS_CONNECT_TIMEOUT) .timeout(JWKS_REQUEST_TIMEOUT) @@ -91,13 +153,30 @@ impl CorporateIdentityService { http, jwks: RwLock::new(None), refresh: Mutex::new(()), + authorization_clock, } } + /// Read the shared verifier clock for portable evidence finalization. + pub fn authorization_now(&self) -> Result { + Ok(self.authorization_clock.now()?.unix_seconds()) + } + /// Validate a JWT and extract the configured corporate identity claims. pub async fn validate_jwt( &self, token: &str, + ) -> Result { + let result = self.validate_jwt_inner(token).await; + if result.is_err() { + metrics::counter!("buzz_jwt_verification_errors_total").increment(1); + } + result + } + + async fn validate_jwt_inner( + &self, + token: &str, ) -> Result { let header = decode_header(token) .map_err(|e| CorporateIdentityError::InvalidJwt(format!("invalid JWT header: {e}")))?; @@ -121,6 +200,10 @@ impl CorporateIdentityService { let decoded = decode::(token, &decoding_key, &validation) .map_err(|e| CorporateIdentityError::InvalidJwt(e.to_string()))?; + validate_optional_iat( + &decoded.claims.claims, + self.authorization_clock.now()?.unix_seconds(), + )?; let issuer = claim_string_exact(&decoded.claims.claims, "iss")?; let uid = claim_string_exact(&decoded.claims.claims, &self.config.uid_claim)?; @@ -150,14 +233,28 @@ impl CorporateIdentityService { { let cache = self.jwks.read().await; if let Some(cached) = cache.as_ref() { - if cached.expires_at > now { + record_jwks_cache_age(cached, now); + if cached.fresh_until > now { if let Some(jwk) = cached.set.find(kid) { return Ok(jwk.clone()); } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); return Err(CorporateIdentityError::Jwks(format!( "kid not found in fresh JWKS cache: {kid}" ))); } + if cached.refresh_after > now { + if cached.hard_expires_at > now { + if let Some(jwk) = cached.set.find(kid) { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk.clone()); + } + } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + return Err(CorporateIdentityError::Jwks( + "JWKS refresh is temporarily unavailable".to_string(), + )); + } } } @@ -165,26 +262,72 @@ impl CorporateIdentityService { // mutex because another waiter may already have populated the cache. let _refresh = self.refresh.lock().await; let now = Instant::now(); - { + let stale_known_key = { let cache = self.jwks.read().await; if let Some(cached) = cache.as_ref() { - if cached.expires_at > now { + record_jwks_cache_age(cached, now); + if cached.fresh_until > now { return cached.set.find(kid).cloned().ok_or_else(|| { + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); CorporateIdentityError::Jwks(format!( "kid not found in fresh JWKS cache: {kid}" )) }); } + if cached.refresh_after > now { + if cached.hard_expires_at > now { + if let Some(jwk) = cached.set.find(kid) { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk.clone()); + } + } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + return Err(CorporateIdentityError::Jwks( + "JWKS refresh is temporarily unavailable".to_string(), + )); + } + if cached.hard_expires_at > now { + cached.set.find(kid).cloned() + } else { + None + } + } else { + None } - } + }; - let set = self.fetch_jwks().await?; + metrics::counter!("buzz_jwks_refresh_total", "result" => "attempt").increment(1); + let set = match self.fetch_jwks().await { + Ok(set) => { + metrics::counter!("buzz_jwks_refresh_total", "result" => "success").increment(1); + set + } + Err(error) => { + metrics::counter!("buzz_jwks_refresh_total", "result" => "failure").increment(1); + let now = Instant::now(); + if let Some(cached) = self.jwks.write().await.as_mut() { + cached.refresh_after = now + JWKS_REFRESH_FAILURE_BACKOFF; + } + if let Some(jwk) = stale_known_key { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk); + } + return Err(error); + } + }; let jwk = set.find(kid).cloned(); + let fetched_at = Instant::now(); *self.jwks.write().await = Some(CachedJwks { set, - expires_at: Instant::now() + JWKS_CACHE_TTL, + fetched_at, + fresh_until: fetched_at + JWKS_CACHE_TTL, + hard_expires_at: fetched_at + JWKS_CACHE_MAX_AGE, + refresh_after: fetched_at + JWKS_CACHE_TTL, }); - jwk.ok_or_else(|| CorporateIdentityError::Jwks(format!("kid not found: {kid}"))) + jwk.ok_or_else(|| { + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + CorporateIdentityError::Jwks(format!("kid not found: {kid}")) + }) } async fn fetch_jwks(&self) -> Result { @@ -242,7 +385,7 @@ fn jwt_validation(algorithm: Algorithm, config: &CorporateIdentityConfig) -> Val /// Callers must complete admission/authorization before passing this proof to /// [`finalize_corporate_identity`]. This ordering prevents rejected requests /// from creating identity bindings or public assertions. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum CorporateIdentityProof { /// Corporate identity is disabled for this relay. NotRequired, @@ -252,6 +395,10 @@ pub enum CorporateIdentityProof { claims: CorporateJwtClaims, /// Binding source selected from the configured npub policy. source: &'static str, + /// Transport provenance proved by the deployment adapter. `None` is + /// retained only for the disabled legacy lane and can never be sealed + /// into protected runtime evidence. + assertion_transport: Option, }, /// A NIP-OA owner with an active binding authorized this agent. Delegated { @@ -264,13 +411,23 @@ pub enum CorporateIdentityProof { }, } +impl fmt::Debug for CorporateIdentityProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NotRequired => "CorporateIdentityProof::NotRequired", + Self::Direct { .. } => "CorporateIdentityProof::Direct([redacted])", + Self::Delegated { .. } => "CorporateIdentityProof::Delegated([redacted])", + }) + } +} + /// Borrow staged direct-identity data for an atomic admission transaction. pub fn binding_input_for_proof<'a>( proof: &'a CorporateIdentityProof, signer: &'a PublicKey, ) -> Option> { match proof { - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { Some(buzz_db::identity_binding::IdentityBindingInput { issuer: &claims.issuer, uid: &claims.uid, @@ -283,13 +440,85 @@ pub fn binding_input_for_proof<'a>( } } +/// Translate a read-only direct verifier result into sealed provider evidence. +pub fn verified_assertion_for_proof( + proof: &CorporateIdentityProof, + authorization_domain: CommunityId, + transport: buzz_auth::AuthTransport, + now_unix_seconds: u64, +) -> Result, CorporateIdentityError> { + let CorporateIdentityProof::Direct { + claims, + assertion_transport, + .. + } = proof + else { + return Ok(None); + }; + let assertion_transport = assertion_transport.ok_or_else(|| { + CorporateIdentityError::Evidence("verified ingress provenance is unavailable".to_owned()) + })?; + buzz_auth::VerifiedEvidenceAdapter::new() + .federated_assertion_from_validated_claims( + authorization_domain, + transport, + &claims.issuer, + &claims.uid, + claims.pubkey, + assertion_transport, + None, + claims.expires_at, + now_unix_seconds, + ) + .map(Some) + .map_err(|error| CorporateIdentityError::Evidence(error.to_string())) +} + +/// Seal current direct evidence with the same clock used by the configured +/// verifier. Non-direct proofs require neither a verifier instance nor time. +pub fn current_verified_assertion_for_proof( + state: &crate::state::AppState, + proof: &CorporateIdentityProof, + authorization_domain: CommunityId, + transport: buzz_auth::AuthTransport, +) -> Result, CorporateIdentityError> { + if !matches!(proof, CorporateIdentityProof::Direct { .. }) { + return Ok(None); + } + if matches!( + proof, + CorporateIdentityProof::Direct { + assertion_transport: None, + .. + } + ) && crate::authorization_runtime::transport::legacy_identity_lane( + state, + authorization_domain, + ) == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + // Off/absent keeps the inherited verifier/finalizer behavior, but an + // unproved header can never be promoted into protected evidence. + return Ok(None); + } + let verifier = state + .corporate_identity + .as_ref() + .ok_or(CorporateIdentityError::VerifierUnavailable)?; + verified_assertion_for_proof( + proof, + authorization_domain, + transport, + verifier.authorization_now()?, + ) +} + /// Whether this proof relies on a delegated owner rather than a direct JWT. pub fn proof_is_delegated(proof: &CorporateIdentityProof) -> bool { matches!(proof, CorporateIdentityProof::Delegated { .. }) } /// Outcome of corporate identity enforcement. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum CorporateIdentityDecision { /// Corporate identity is disabled for this relay. NotRequired, @@ -317,6 +546,16 @@ pub enum CorporateIdentityDecision { }, } +impl fmt::Debug for CorporateIdentityDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NotRequired => "CorporateIdentityDecision::NotRequired", + Self::Direct { .. } => "CorporateIdentityDecision::Direct([redacted])", + Self::Delegated { .. } => "CorporateIdentityDecision::Delegated([redacted])", + }) + } +} + struct SessionRevalidationPlan { binding_pubkey: PublicKey, expected_issuer: String, @@ -368,8 +607,8 @@ async fn cancel_session_at_expiry( async fn run_session_binding_revalidation( interval: Duration, - signer: PublicKey, - binding_pubkey: PublicKey, + _signer: PublicKey, + _binding_pubkey: PublicKey, expected_issuer: String, expected_uid: String, cancel: tokio_util::sync::CancellationToken, @@ -391,20 +630,12 @@ async fn run_session_binding_revalidation( Ok(Some(binding)) if binding.issuer == expected_issuer && binding.uid == expected_uid => {} Ok(Some(_)) | Ok(None) => { - warn!( - signer = %signer.to_hex(), - binding_pubkey = %binding_pubkey.to_hex(), - "corporate identity session evicted after binding revocation" - ); + warn!("corporate identity session evicted after binding revocation"); cancel.cancel(); return; } - Err(error) => { - warn!( - signer = %signer.to_hex(), - error = %error, - "corporate identity session revalidation failed closed" - ); + Err(_error) => { + warn!("corporate identity session revalidation failed closed"); cancel.cancel(); return; } @@ -465,8 +696,20 @@ pub fn spawn_session_revalidation( } /// Errors produced by corporate identity verification. -#[derive(Debug, Error)] +#[derive(Error)] pub enum CorporateIdentityError { + /// Direct identity evidence existed without its configured verifier. + #[error("relay identity verifier unavailable")] + VerifierUnavailable, + /// The configured identity header was duplicated, combined, or malformed. + #[error("relay identity header is ambiguous")] + AmbiguousIdentityHeader, + /// Protected identity evidence lacked deployment-verified provenance. + #[error("relay identity transport provenance is unavailable")] + UntrustedIdentityTransport, + /// The shared authorization clock could not provide verifier time. + #[error("corporate identity authorization clock unavailable")] + AuthorizationClock(#[from] AuthorizationClockError), /// No JWT was available and delegation did not apply. #[error("corporate identity JWT missing")] MissingJwt, @@ -502,32 +745,55 @@ pub enum CorporateIdentityError { /// NIP-OA delegation was present but did not satisfy corporate identity. #[error("corporate identity delegation denied")] DelegationDenied, + /// Verified claims could not be sealed as portable assertion evidence. + #[error("corporate identity evidence is inconsistent: {0}")] + Evidence(String), /// Database operation failed. #[error("corporate identity database error: {0}")] Db(#[from] buzz_db::DbError), } +impl fmt::Debug for CorporateIdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CorporateIdentityError") + .field("reason", &self.reason_code()) + .finish() + } +} + impl CorporateIdentityError { /// HTTP status appropriate for this error. pub fn status_code(&self) -> StatusCode { match self { - Self::MissingJwt | Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { - StatusCode::UNAUTHORIZED - } + Self::AuthorizationClock(_) + | Self::AmbiguousIdentityHeader + | Self::UntrustedIdentityTransport + | Self::MissingJwt + | Self::MissingKid + | Self::InvalidJwt(_) + | Self::Jwks(_) => StatusCode::UNAUTHORIZED, Self::InvalidClaim { .. } | Self::NpubMismatch | Self::BindingConflict | Self::BindingRevoked | Self::BindingRequired - | Self::DelegationDenied => StatusCode::FORBIDDEN, - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + | Self::DelegationDenied + | Self::Evidence(_) => StatusCode::FORBIDDEN, + Self::VerifierUnavailable | Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, } } /// Sanitized message safe to return to clients. pub fn public_message(&self) -> &'static str { match self { + Self::VerifierUnavailable | Self::AuthorizationClock(_) => { + "relay identity verification unavailable" + } Self::MissingJwt => "relay-verified identity required", + Self::AmbiguousIdentityHeader | Self::UntrustedIdentityTransport => { + "relay identity verification failed" + } Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { "relay identity verification failed" } @@ -537,6 +803,7 @@ impl CorporateIdentityError { Self::BindingRevoked => "relay identity binding revoked", Self::BindingRequired => "relay identity binding required", Self::DelegationDenied => "relay identity delegation denied", + Self::Evidence(_) => "relay identity verification failed", Self::Db(_) => "relay identity unavailable", } } @@ -546,31 +813,137 @@ impl CorporateIdentityError { let status = self.status_code(); let message = self.public_message(); if status.is_server_error() { - warn!(error = %self, "corporate identity enforcement failed"); + warn!( + reason = self.reason_code(), + "corporate identity enforcement failed" + ); } (status, Json(serde_json::json!({ "error": message }))) } + + /// Stable, non-sensitive diagnostic class for metrics and runtime logs. + pub(crate) fn reason_code(&self) -> &'static str { + match self { + Self::VerifierUnavailable => "verifier_unavailable", + Self::AmbiguousIdentityHeader => "ambiguous_identity_header", + Self::UntrustedIdentityTransport => "untrusted_identity_transport", + Self::AuthorizationClock(_) => "authorization_clock", + Self::MissingJwt => "missing_jwt", + Self::MissingKid => "missing_kid", + Self::InvalidJwt(_) => "invalid_jwt", + Self::Jwks(_) => "jwks", + Self::InvalidClaim { .. } => "invalid_claim", + Self::NpubMismatch => "npub_mismatch", + Self::BindingConflict => "binding_conflict", + Self::BindingRevoked => "binding_revoked", + Self::BindingRequired => "binding_required", + Self::DelegationDenied => "delegation_denied", + Self::Evidence(_) => "evidence", + Self::Db(_) => "db", + } + } +} + +/// Deployment-owned proof that a direct assertion arrived through a reviewed +/// ingress boundary. +/// +/// The OSS relay deliberately does not infer provenance from the presence of +/// an identity header. A composition root must install an implementation that +/// validates immediate-caller transport evidence and confirms that inbound +/// copies were stripped before exactly one value was set. +pub trait IdentityAssertionProvenanceVerifier: Send + Sync { + /// Return the provider-neutral assertion transport proved for this request. + fn verify( + &self, + headers: &HeaderMap, + ) -> Result; +} + +/// One exact direct assertion captured at the request boundary. +#[derive(Clone, PartialEq, Eq)] +pub struct IdentityAssertionInput { + jwt: String, + assertion_transport: Option, +} + +impl IdentityAssertionInput { + /// Validated header payload passed only to the JWT verifier. + pub fn jwt(&self) -> &str { + &self.jwt + } } -/// Extract a corporate identity JWT from the configured request header. +impl fmt::Debug for IdentityAssertionInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityAssertionInput") + .field("jwt", &"[redacted]") + .field("assertion_transport", &self.assertion_transport) + .finish() + } +} + +/// Extract exactly one corporate identity JWT from the configured header. +/// +/// RFC 9110 permits intermediaries to combine repeated field lines. Identity +/// evidence is not list-valued, so both multiple field lines and any comma are +/// rejected rather than applying a first/last-wins rule. pub fn identity_jwt_from_headers( headers: &HeaderMap, config: &CorporateIdentityConfig, -) -> Option { - headers - .get(config.jwt_header.as_str()) - .and_then(|v| v.to_str().ok()) - .map(str::trim) - .and_then(|raw| { - raw.strip_prefix("Bearer ") - .unwrap_or(raw) - .trim() - .split(',') - .next() - }) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) +) -> Result, CorporateIdentityError> { + let mut values = headers.get_all(config.jwt_header.as_str()).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + let raw = value + .to_str() + .map_err(|_| CorporateIdentityError::AmbiguousIdentityHeader)? + .trim(); + if raw.contains(',') { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + let jwt = raw.strip_prefix("Bearer ").unwrap_or(raw).trim(); + if jwt.is_empty() { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + Ok(Some(jwt.to_owned())) +} + +/// Extract direct identity evidence and attach only deployment-proved +/// transport provenance. Disabled/Off legacy behavior can retain the header +/// payload, but cannot turn it into protected runtime evidence. +pub fn identity_assertion_from_headers( + state: &AppState, + authorization_domain: CommunityId, + headers: &HeaderMap, +) -> Result, CorporateIdentityError> { + let Some(jwt) = identity_jwt_from_headers(headers, &state.config.corporate_identity)? else { + return Ok(None); + }; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)); + let assertion_transport = match mode { + None | Some(crate::authorization_runtime::finalization::AuthorizationMode::Off) => None, + Some(_) => { + let verifier = state + .identity_assertion_provenance() + .ok_or(CorporateIdentityError::UntrustedIdentityTransport)?; + let transport = verifier.verify(headers)?; + if transport != buzz_auth::AssertionTransport::TrustedProxy { + return Err(CorporateIdentityError::UntrustedIdentityTransport); + } + Some(transport) + } + }; + Ok(Some(IdentityAssertionInput { + jwt, + assertion_transport, + })) } /// Validate corporate identity without creating bindings or assertions. @@ -578,12 +951,17 @@ pub async fn verify_corporate_identity( state: &AppState, community_id: CommunityId, signer: PublicKey, - identity_jwt: Option<&str>, + identity_assertion: Option<&IdentityAssertionInput>, auth_tag_json: Option<&str>, ) -> Result { - let result = - verify_corporate_identity_inner(state, community_id, signer, identity_jwt, auth_tag_json) - .await; + let result = verify_corporate_identity_inner( + state, + community_id, + signer, + identity_assertion, + auth_tag_json, + ) + .await; if let Err(error) = &result { record_corporate_identity_denial(error); } @@ -594,20 +972,29 @@ async fn verify_corporate_identity_inner( state: &AppState, community_id: CommunityId, signer: PublicKey, - identity_jwt: Option<&str>, + identity_assertion: Option<&IdentityAssertionInput>, auth_tag_json: Option<&str>, ) -> Result { let Some(service) = state.corporate_identity.as_ref() else { return Ok(CorporateIdentityProof::NotRequired); }; + if !service.config.require + && crate::authorization_runtime::transport::legacy_identity_lane(state, community_id) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(CorporateIdentityProof::NotRequired); + } // Requests can carry both a direct identity JWT and a cryptographically // verified NIP-OA owner declaration. The deployment selects which identity // source wins; the provider-neutral default treats the JWT as the signer's // identity. Delegated precedence supports identity-aware gateways that // attach an owner's token to requests made by that owner's agents. - if select_identity_auth_path(&service.config, identity_jwt, auth_tag_json) - == IdentityAuthPath::Delegated + if select_identity_auth_path( + &service.config, + identity_assertion.map(IdentityAssertionInput::jwt), + auth_tag_json, + ) == IdentityAuthPath::Delegated { return verify_delegated_corporate_identity( &state.db, @@ -619,10 +1006,14 @@ async fn verify_corporate_identity_inner( .await; } - if let Some(token) = identity_jwt { - let claims = service.validate_jwt(token).await?; + if let Some(assertion) = identity_assertion { + let claims = service.validate_jwt(assertion.jwt()).await?; let source = binding_source_for_signer(claims.pubkey, signer)?; - return Ok(CorporateIdentityProof::Direct { claims, source }); + return Ok(CorporateIdentityProof::Direct { + claims, + source, + assertion_transport: assertion.assertion_transport, + }); } verify_delegated_corporate_identity( @@ -670,7 +1061,7 @@ pub async fn finalize_atomic_corporate_identity_result( owner_issuer, owner_uid, }), - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { let binding = committed_binding.ok_or_else(|| { buzz_db::DbError::InvalidData( "atomic identity admission did not return a binding result".to_string(), @@ -703,7 +1094,7 @@ async fn finalize_corporate_identity_inner( owner_issuer, owner_uid, }), - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { let binding = state .db .bind_or_validate_identity( @@ -788,33 +1179,31 @@ async fn complete_direct_corporate_identity( ) .await; } - if let Err(error) = ensure_identity_assertion( - state, - community_id, - signer, - claims.public_display_name.as_deref(), - claims.expires_at, - ) - .await - { - // The binding remains the authorization authority. A projection - // failure removes the verified affordance but must not lock an - // otherwise authorized user out of the relay. - warn!( - signer = %signer.to_hex(), - error = %error, - "failed to publish corporate identity assertion" - ); - metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") - .increment(1); + let projection_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + if public_projection_mutation_enabled(projection_mode) { + if let Err(_error) = ensure_identity_assertion( + state, + community_id, + signer, + &claims.issuer, + &claims.uid, + claims.public_display_name.as_deref(), + claims.expires_at, + ) + .await + { + // The binding remains the authorization authority. A projection + // failure removes the verified affordance but must not lock an + // otherwise authorized user out of the relay. + warn!("failed to publish corporate identity assertion"); + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") + .increment(1); + } } - debug!( - uid = %claims.uid, - signer = %signer.to_hex(), - source, - "corporate identity verified" - ); + debug!(source, "corporate identity verified"); Ok(CorporateIdentityDecision::Direct { issuer: claims.issuer, uid: claims.uid, @@ -865,6 +1254,45 @@ fn identity_assertion_matches( subject: &str, display_name: Option<&str>, expires_at: u64, +) -> bool { + let exact_tag_count = |name: &str, value: &str| { + event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + .count() + }; + event.content.is_empty() + && event.tags.len() == if display_name.is_some() { 6 } else { 5 } + && exact_tag_count("d", subject) == 1 + && exact_tag_count("p", subject) == 1 + && exact_tag_count("verified", "relay") == 1 + && exact_tag_count( + "active", + if display_name.is_some() { + "true" + } else { + "false" + }, + ) == 1 + && exact_tag_count("expiration", &expires_at.to_string()) == 1 + && match display_name { + Some(name) => exact_tag_count("display_name", name) == 1, + None => !event.tags.iter().any(|tag| { + tag.as_slice() + .first() + .is_some_and(|part| part == "display_name") + }), + } +} + +fn identity_assertion_has_base_shape( + event: &Event, + relay_author: PublicKey, + subject: &str, ) -> bool { let has_tag = |name: &str, value: &str| { event.tags.iter().any(|tag| { @@ -872,43 +1300,38 @@ fn identity_assertion_matches( parts.len() == 2 && parts[0] == name && parts[1] == value }) }; - has_tag("d", subject) + event.content.is_empty() + && event.kind.as_u16() as u32 == KIND_USER_TRUSTED_ASSERTION + && event.pubkey == relay_author + && event.verify_id() + && event.verify_signature() + && has_tag("d", subject) && has_tag("p", subject) && has_tag("verified", "relay") - && has_tag( - "active", - if display_name.is_some() { - "true" - } else { - "false" - }, - ) - && has_tag("expiration", &expires_at.to_string()) - && display_name.is_none_or(|name| has_tag("display_name", name)) } async fn ensure_identity_assertion( state: &AppState, community_id: CommunityId, subject: PublicKey, + issuer: &str, + uid: &str, display_name: Option<&str>, jwt_expires_at: u64, ) -> Result<(), String> { let subject_hex = subject.to_hex(); - let existing = state - .db - .query_events(&EventQuery { - kinds: Some(vec![KIND_USER_TRUSTED_ASSERTION as i32]), - pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), - d_tag: Some(subject_hex.clone()), - global_only: true, - limit: Some(1), - ..EventQuery::for_community(community_id) - }) - .await - .map_err(|error| error.to_string())? - .into_iter() - .next(); + let permit = buzz_db::public_projection::begin_active_public_projection( + &state.db, + community_id, + state.relay_keypair.public_key().as_bytes(), + issuer, + uid, + subject.as_bytes(), + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "identity binding changed before public projection".to_owned())?; + let existing = permit.current_projection().cloned(); // Privacy default: do not publish any assertion unless the operator opted // into a public label. An inactive replacement is emitted only to retire a @@ -919,9 +1342,20 @@ async fn ensure_identity_assertion( let now = Timestamp::now().as_secs(); let expires_at = identity_assertion_expiration(display_name, jwt_expires_at, now); - if existing.as_ref().is_some_and(|stored| { + if let Some(existing_match) = existing.as_ref().filter(|stored| { identity_assertion_matches(&stored.event, &subject_hex, display_name, expires_at) }) { + permit + .commit( + &existing_match.event, + if display_name.is_some() { + buzz_db::public_projection::ProjectionDisposition::Active + } else { + buzz_db::public_projection::ProjectionDisposition::Inactive + }, + ) + .await + .map_err(|error| error.to_string())?; return Ok(()); } @@ -938,9 +1372,15 @@ async fn ensure_identity_assertion( Timestamp::from(created_at), )?; - state - .db - .replace_parameterized_event(community_id, &event, &subject_hex, None) + permit + .commit( + &event, + if display_name.is_some() { + buzz_db::public_projection::ProjectionDisposition::Active + } else { + buzz_db::public_projection::ProjectionDisposition::Inactive + }, + ) .await .map_err(|error| error.to_string())?; metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "published") @@ -956,6 +1396,214 @@ fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64 } } +/// Internal O4 reconciliation failure. This carries no identity or token data. +#[derive(Debug, Error)] +pub(crate) enum PublicProjectionReconciliationError { + /// Durable projection state was unavailable or inconsistent. + #[error("public identity projection database state is unavailable")] + Database(#[from] buzz_db::DbError), + /// Shared authorization time was unavailable. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// A stored event did not satisfy the exact public projection contract. + #[error("stored public identity projection is invalid")] + InvalidProjection, + /// The relay could not construct the canonical inactive event. + #[error("failed to construct inactive public identity projection")] + Build, + /// A configured domain lacked its durable tenant mapping. + #[error("public identity projection domain is unavailable")] + DomainUnavailable, + /// Cross-replica withdrawal delivery failed and remains retryable. + #[error("public identity projection delivery is unavailable")] + DeliveryUnavailable, + /// Startup did not reach a complete reconciliation fixed point. + #[error("public identity projection reconciliation is incomplete")] + Incomplete, +} + +async fn reconcile_one_public_projection( + state: &AppState, + domains: &[CommunityId], +) -> Result { + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + buzz_db::public_projection::materialize_public_projection_retirements( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await?; + + if let Some(claim) = buzz_db::public_projection::claim_public_projection_retirement( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await? + { + let permit = + buzz_db::public_projection::begin_public_projection_retirement(&state.db, claim) + .await?; + let Some(current) = permit.current_projection().cloned() else { + permit.finish_no_projection().await?; + return Ok(true); + }; + let old_key = match PublicKey::from_slice(permit.old_pubkey()) { + Ok(key) => key, + Err(_) => { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + }; + let subject = old_key.to_hex(); + if !identity_assertion_has_base_shape( + ¤t.event, + state.relay_keypair.public_key(), + &subject, + ) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + let current_is_inactive = identity_assertion_matches(¤t.event, &subject, None, 0); + if permit.head().is_some_and(|head| { + head.event_id() != *current.event.id.as_bytes() + || head.disposition() + != if current_is_inactive { + buzz_db::public_projection::ProjectionDisposition::Inactive + } else { + buzz_db::public_projection::ProjectionDisposition::Active + } + }) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + if current_is_inactive { + permit.finish_existing_inactive().await?; + return Ok(true); + } + let head_origin = permit.head().and_then(|head| head.origin()); + if let Some(active_origin) = permit.active_origin() { + if permit.source_origin() == Some(active_origin) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + if head_origin == Some(active_origin) { + permit.finish_superseded(¤t.event).await?; + return Ok(true); + } + } + if let (Some(source), Some(origin)) = (permit.source_origin(), head_origin) { + let belongs_to_later_generation = if origin.binding_id() == source.binding_id() { + origin.binding_version() > source.binding_version() + } else { + true + }; + if belongs_to_later_generation { + permit.finish_newer_projection().await?; + return Ok(true); + } + } + let now = SystemAuthorizationClock.now()?.unix_seconds(); + let Some(next_created_at) = current.event.created_at.as_secs().checked_add(1) else { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::Build); + }; + let inactive = match build_identity_assertion( + &state.relay_keypair, + old_key, + None, + 0, + Timestamp::from(next_created_at.max(now)), + ) { + Ok(event) => event, + Err(_) => { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::Build); + } + }; + permit.finish_inactive(&inactive).await?; + return Ok(true); + } + + let Some(delivery) = buzz_db::public_projection::begin_public_projection_delivery( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await? + else { + return Ok(false); + }; + let domain = delivery.community_id(); + let tenant = state + .db + .usage_community_hosts() + .await? + .into_iter() + .find(|entry| CommunityId::from_uuid(entry.id) == domain) + .map(|entry| buzz_core::TenantContext::resolved(domain, entry.host)); + let Some(tenant) = tenant else { + delivery.defer().await?; + return Err(PublicProjectionReconciliationError::DomainUnavailable); + }; + state.mark_local_event(domain, &delivery.stored().event.id); + if state + .pubsub + .publish_event(&tenant, EventTopic::Global, &delivery.stored().event) + .await + .is_err() + { + state + .local_event_ids + .invalidate(&(domain, delivery.stored().event.id.to_bytes())); + delivery.defer().await?; + return Err(PublicProjectionReconciliationError::DeliveryUnavailable); + } + crate::handlers::event::fan_out_event_to_local_subscribers(state, domain, delivery.stored()) + .await; + delivery.complete().await?; + Ok(true) +} + +/// Drain every materialized retirement before protected routes become reachable. +pub(crate) async fn reconcile_public_projection_retirements_startup( + state: &AppState, + domains: &[CommunityId], +) -> Result<(), PublicProjectionReconciliationError> { + for _ in 0..PUBLIC_PROJECTION_STARTUP_LIMIT { + if !reconcile_one_public_projection(state, domains).await? { + break; + } + } + let unfinished = buzz_db::public_projection::unfinished_public_projection_retirements( + &state.db, + domains, + state.relay_keypair.public_key().as_bytes(), + ) + .await?; + if unfinished != 0 { + return Err(PublicProjectionReconciliationError::Incomplete); + } + Ok(()) +} + +/// Continuously discover committed lifecycle rows and retry withdrawal/delivery. +pub(crate) async fn run_public_projection_retirement_reconciliation( + state: Arc, + domains: Vec, +) { + loop { + match reconcile_one_public_projection(state.as_ref(), &domains).await { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!(%error, "public identity projection reconciliation deferred") + } + } + tokio::time::sleep(PUBLIC_PROJECTION_RECONCILIATION_INTERVAL).await; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IdentityAuthPath { Direct, @@ -985,16 +1633,12 @@ async fn verify_delegated_corporate_identity( auth_tag_json: Option<&str>, ) -> Result { if config.allow_delegation { - if let Some(owner_pubkey) = extract_unconditional_nip_oa_owner(signer, auth_tag_json) { + if let Some(owner_pubkey) = verify_unconditional_nip_oa_owner(signer, auth_tag_json) { let owner_binding = db .get_active_identity_binding_by_pubkey(community_id, owner_pubkey.as_bytes()) .await?; if let Some(owner_binding) = owner_binding { - debug!( - agent = %signer.to_hex(), - owner = %owner_pubkey.to_hex(), - "corporate identity granted via NIP-OA owner binding" - ); + debug!("corporate identity granted via NIP-OA owner binding"); return Ok(CorporateIdentityProof::Delegated { owner_pubkey, owner_issuer: owner_binding.issuer, @@ -1010,7 +1654,11 @@ async fn verify_delegated_corporate_identity( } } -fn extract_unconditional_nip_oa_owner( +/// Verify an unconditional NIP-OA owner attestation for transport-wide use. +/// +/// Conditional attestations are deliberately rejected because their narrower +/// event constraints cannot be promoted into connection or request authority. +pub fn verify_unconditional_nip_oa_owner( signer: PublicKey, auth_tag_json: Option<&str>, ) -> Option { @@ -1095,11 +1743,7 @@ fn binding_source_for_signer( match claim_pubkey { Some(claim_pubkey) => { if claim_pubkey != signer { - warn!( - signer = %signer.to_hex(), - claim_pubkey = %claim_pubkey.to_hex(), - "corporate identity JWT npub claim does not match signer" - ); + warn!("corporate identity JWT npub claim does not match signer"); return Err(CorporateIdentityError::NpubMismatch); } Ok(SOURCE_JWT_NPUB) @@ -1166,6 +1810,28 @@ fn claim_u64(claims: &Map, claim: &str) -> Result, + verifier_time: u64, +) -> Result<(), CorporateIdentityError> { + let Some(value) = claims.get("iat") else { + return Ok(()); + }; + let issued_at = value + .as_u64() + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: "iat".to_string(), + reason: "must be an unsigned integer when present".to_string(), + })?; + if issued_at > verifier_time.saturating_add(JWT_CLOCK_SKEW_LEEWAY_SECS) { + return Err(CorporateIdentityError::InvalidClaim { + claim: "iat".to_string(), + reason: "must not be later than verifier time plus bounded skew".to_string(), + }); + } + Ok(()) +} + fn parse_pubkey_claim(claim: &str, value: &str) -> Result { if value.starts_with("npub1") { PublicKey::from_bech32(value).map_err(|e| CorporateIdentityError::InvalidClaim { @@ -1185,7 +1851,7 @@ pub fn service_from_config( config: &CorporateIdentityConfig, ) -> Option> { config - .require + .verifier_configured() .then(|| Arc::new(CorporateIdentityService::new(config.clone()))) } @@ -1200,23 +1866,25 @@ fn record_identity_binding_metric(binding: &BindIdentityResult) { metrics::counter!("buzz_corporate_identity_bindings_total", "result" => result).increment(1); } +const fn public_projection_mutation_enabled( + mode: Option, +) -> bool { + use crate::authorization_runtime::finalization::AuthorizationMode; + + matches!( + mode, + None | Some(AuthorizationMode::Off) | Some(AuthorizationMode::Enforce) + ) +} + fn record_corporate_identity_denial(error: &CorporateIdentityError) { - let reason = match error { - CorporateIdentityError::MissingJwt => "missing_jwt", - CorporateIdentityError::MissingKid => "missing_kid", - CorporateIdentityError::InvalidJwt(_) => "invalid_jwt", - CorporateIdentityError::Jwks(_) => "jwks", - CorporateIdentityError::InvalidClaim { .. } => "invalid_claim", - CorporateIdentityError::NpubMismatch => "npub_mismatch", - CorporateIdentityError::BindingConflict => "binding_conflict", - CorporateIdentityError::BindingRevoked => "binding_revoked", - CorporateIdentityError::BindingRequired => "binding_required", - CorporateIdentityError::DelegationDenied => "delegation_denied", - CorporateIdentityError::Db(_) => "db", - }; metrics::counter!("buzz_auth_failures_total", "reason" => "corporate_identity_denied") .increment(1); - metrics::counter!("buzz_corporate_identity_denials_total", "reason" => reason).increment(1); + metrics::counter!( + "buzz_corporate_identity_denials_total", + "reason" => error.reason_code() + ) + .increment(1); } async fn record_identity_binding_audit( @@ -1228,6 +1896,16 @@ async fn record_identity_binding_audit( uid: &str, detail: serde_json::Value, ) { + if crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_err() + { + return; + } let Some(audit_tx) = &state.audit_tx else { return; }; @@ -1256,6 +1934,7 @@ mod tests { use axum::http::{HeaderMap, HeaderName, HeaderValue}; use base64::Engine as _; + use buzz_auth::{AuthorizationClock, AuthorizationTime}; use jsonwebtoken::jwk::JwkSet; use jsonwebtoken::{encode, EncodingKey, Header}; use nostr::Keys; @@ -1266,6 +1945,131 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + struct FixedAuthorizationClock(u64); + + impl AuthorizationClock for FixedAuthorizationClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(self.0)) + } + } + + #[test] + fn observational_modes_never_mutate_the_public_projection() { + use crate::authorization_runtime::finalization::AuthorizationMode; + + assert!(public_projection_mutation_enabled(None)); + assert!(public_projection_mutation_enabled(Some( + AuthorizationMode::Off + ))); + assert!(public_projection_mutation_enabled(Some( + AuthorizationMode::Enforce + ))); + assert!(!public_projection_mutation_enabled(Some( + AuthorizationMode::Shadow + ))); + assert!(!public_projection_mutation_enabled(Some( + AuthorizationMode::VerifyOnly + ))); + } + + #[test] + fn identity_debug_never_exposes_provider_or_principal_data() { + let pubkey = Keys::generate().public_key(); + let claims = CorporateJwtClaims { + issuer: "https://private-issuer.invalid".into(), + uid: "private-subject".into(), + display_name: "Private Person".into(), + public_display_name: Some("Public Label".into()), + pubkey: Some(pubkey), + expires_at: 1_234_567, + }; + let proof = CorporateIdentityProof::Direct { + claims: claims.clone(), + source: "private-source", + assertion_transport: Some(buzz_auth::AssertionTransport::TrustedProxy), + }; + let decision = CorporateIdentityDecision::Delegated { + owner_pubkey: pubkey, + owner_issuer: claims.issuer.clone(), + owner_uid: claims.uid.clone(), + }; + let error = CorporateIdentityError::InvalidClaim { + claim: "private-claim-name".into(), + reason: "private-claim-value".into(), + }; + + for debug in [ + format!("{claims:?}"), + format!("{proof:?}"), + format!("{decision:?}"), + format!("{error:?}"), + ] { + for secret in [ + "private-issuer", + "private-subject", + "Private Person", + "Public Label", + "private-source", + "private-claim-name", + "private-claim-value", + &pubkey.to_hex(), + "1234567", + ] { + assert!( + !debug.contains(secret), + "debug output exposed {secret:?}: {debug}" + ); + } + } + } + + #[test] + fn direct_identity_requires_verified_transport_provenance_before_sealing() { + let claims = CorporateJwtClaims { + issuer: "https://issuer.example".into(), + uid: "synthetic-subject".into(), + display_name: "Synthetic User".into(), + public_display_name: None, + pubkey: Some(Keys::generate().public_key()), + expires_at: 2_000, + }; + let domain = CommunityId::from_uuid(Uuid::nil()); + let unproved = CorporateIdentityProof::Direct { + claims: claims.clone(), + source: SOURCE_JWT_NPUB, + assertion_transport: None, + }; + + assert!(matches!( + verified_assertion_for_proof( + &unproved, + domain, + buzz_auth::AuthTransport::RelayWebSocket, + 1_000, + ), + Err(CorporateIdentityError::Evidence(_)) + )); + + let proved = CorporateIdentityProof::Direct { + claims, + source: SOURCE_JWT_NPUB, + assertion_transport: Some(buzz_auth::AssertionTransport::TrustedProxy), + }; + let assertion = verified_assertion_for_proof( + &proved, + domain, + buzz_auth::AuthTransport::RelayWebSocket, + 1_000, + ) + .expect("synthetic proved assertion should seal") + .expect("direct identity should produce a sealed assertion"); + + assert_eq!( + assertion.transport(), + buzz_auth::AssertionTransport::TrustedProxy + ); + } + fn test_config() -> CorporateIdentityConfig { CorporateIdentityConfig { require: true, @@ -1519,6 +2323,59 @@ mod tests { .first() .is_some_and(|part| part == "display_name") })); + + let subject_hex = subject.to_hex(); + let malformed = EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + Tag::parse(["d", subject_hex.as_str()]).expect("d tag"), + Tag::parse(["p", subject_hex.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", "false"]).expect("inactive tag"), + Tag::parse(["expiration", "0"]).expect("expiration tag"), + Tag::parse(["display_name", "Forbidden stale label"]).expect("display tag"), + ]) + .custom_created_at(Timestamp::from(now + 1)) + .sign_with_keys(&relay) + .expect("sign malformed inactive assertion"); + assert!(!identity_assertion_matches( + &malformed, + &subject_hex, + None, + 0, + )); + + for (display_name, expires_at, at) in [ + (Some("Example User"), now + 60, now + 2), + (None, 0, now + 3), + ] { + let canonical = build_identity_assertion( + &relay, + subject, + display_name, + expires_at, + Timestamp::from(at), + ) + .expect("build canonical assertion"); + let nonempty = EventBuilder::new( + Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "private content must never be projected", + ) + .tags(canonical.tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(&relay) + .expect("sign non-canonical assertion"); + assert!(!identity_assertion_matches( + &nonempty, + &subject_hex, + display_name, + expires_at, + )); + assert!(!identity_assertion_has_base_shape( + &nonempty, + relay.public_key(), + &subject_hex, + )); + } } #[test] @@ -1588,6 +2445,38 @@ mod tests { assert_eq!(claims.display_name, "user@example.com"); } + #[tokio::test] + async fn validate_jwt_rejects_future_and_malformed_optional_iat() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let now = 2_000_000_000; + let clock: SharedAuthorizationClock = Arc::new(FixedAuthorizationClock(now)); + + let mut within_skew = valid_test_claims(now); + within_skew["iat"] = Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS); + let token = rsa_test_jwt_with_claims(&key, "rsa-key", &within_skew); + validate_rsa_jwt_at(&token, rsa_test_jwk(&key, "rsa-key"), Arc::clone(&clock)) + .await + .expect("optional iat within bounded skew validates"); + + for iat in [ + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS + 600), + Value::String("tomorrow".to_string()), + ] { + let mut claims = valid_test_claims(now); + claims["iat"] = iat; + let token = rsa_test_jwt_with_claims(&key, "rsa-key", &claims); + let error = + validate_rsa_jwt_at(&token, rsa_test_jwk(&key, "rsa-key"), Arc::clone(&clock)) + .await + .expect_err("future or malformed optional iat must fail closed"); + match error { + CorporateIdentityError::InvalidClaim { claim, .. } => assert_eq!(claim, "iat"), + CorporateIdentityError::InvalidJwt(_) => {} + other => panic!("unexpected optional iat error: {other:?}"), + } + } + } + #[tokio::test] async fn validate_jwt_rejects_rs256_token_signed_by_wrong_key() { let signing_key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); @@ -1704,6 +2593,36 @@ mod tests { assert_eq!(validation.leeway, JWT_CLOCK_SKEW_LEEWAY_SECS); } + #[test] + fn optional_iat_is_not_required_and_is_bounded_by_verifier_time() { + let now = 1_800_000_000; + let mut claims = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + assert!(validate_optional_iat(&claims, now).is_ok()); + + claims.insert( + "iat".to_string(), + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS), + ); + assert!(validate_optional_iat(&claims, now).is_ok()); + + for invalid in [ + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS + 1), + Value::String("tomorrow".to_string()), + Value::from(-1), + Value::from(1.5), + Value::Null, + ] { + claims.insert("iat".to_string(), invalid); + assert!(matches!( + validate_optional_iat(&claims, now), + Err(CorporateIdentityError::InvalidClaim { ref claim, .. }) if claim == "iat" + )); + } + } + #[test] fn jwt_validation_rejects_malformed_registered_claim_types() { let now = Timestamp::now().as_secs(); @@ -1799,7 +2718,7 @@ mod tests { } #[test] - fn extracts_bearer_token_from_comma_list_header() { + fn rejects_ambiguous_identity_header_values() { let config = test_config(); let mut headers = HeaderMap::new(); headers.insert( @@ -1807,10 +2726,18 @@ mod tests { HeaderValue::from_static("Bearer token-a, Bearer token-b"), ); - assert_eq!( - identity_jwt_from_headers(&headers, &config).as_deref(), - Some("token-a") + assert!(identity_jwt_from_headers(&headers, &config).is_err()); + + headers.remove("x-buzz-identity-token"); + headers.append( + HeaderName::from_static("x-buzz-identity-token"), + HeaderValue::from_static("Bearer token-a"), + ); + headers.append( + HeaderName::from_static("x-buzz-identity-token"), + HeaderValue::from_static("Bearer token-b"), ); + assert!(identity_jwt_from_headers(&headers, &config).is_err()); } #[test] @@ -1867,9 +2794,13 @@ mod tests { #[tokio::test] async fn fresh_jwks_cache_miss_does_not_refetch() { let service = CorporateIdentityService::new(test_config()); + let fetched_at = Instant::now(); *service.jwks.write().await = Some(CachedJwks { set: JwkSet { keys: Vec::new() }, - expires_at: Instant::now() + Duration::from_secs(60), + fetched_at, + fresh_until: fetched_at + Duration::from_secs(60), + hard_expires_at: fetched_at + JWKS_CACHE_MAX_AGE, + refresh_after: fetched_at + Duration::from_secs(60), }); let err = service @@ -1958,11 +2889,11 @@ mod tests { .expect("conditional auth tag"); assert_eq!( - extract_unconditional_nip_oa_owner(agent, Some(&unconditional)), + verify_unconditional_nip_oa_owner(agent, Some(&unconditional)), Some(owner.public_key()), ); assert_eq!( - extract_unconditional_nip_oa_owner(agent, Some(&conditional)), + verify_unconditional_nip_oa_owner(agent, Some(&conditional)), None, ); } @@ -2013,19 +2944,31 @@ mod tests { } fn rsa_test_jwt(private_key: &[u8], kid: &str) -> String { - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(kid.to_string()); - encode( - &header, + rsa_test_jwt_with_claims( + private_key, + kid, &valid_test_claims(Timestamp::now().as_secs()), - &EncodingKey::from_rsa_der(private_key), ) - .expect("encode RSA test JWT") + } + + fn rsa_test_jwt_with_claims(private_key: &[u8], kid: &str, claims: &Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode(&header, claims, &EncodingKey::from_rsa_der(private_key)) + .expect("encode RSA test JWT") } async fn validate_rsa_jwt( token: &str, jwk: Jwk, + ) -> Result { + validate_rsa_jwt_at(token, jwk, Arc::new(SystemAuthorizationClock)).await + } + + async fn validate_rsa_jwt_at( + token: &str, + jwk: Jwk, + clock: SharedAuthorizationClock, ) -> Result { let body = serde_json::to_string(&JwkSet { keys: vec![jwk] }).expect("serialize RSA test JWKS"); @@ -2034,7 +2977,7 @@ mod tests { let mut config = test_config(); config.jwks_uri = uri; config.npub_claim = None; - let result = CorporateIdentityService::new(config) + let result = CorporateIdentityService::with_authorization_clock(config, clock) .validate_jwt(token) .await; server.abort(); @@ -2106,6 +3049,264 @@ mod tests { CommunityId::from_uuid(id) } + async fn projection_test_state( + db: buzz_db::Db, + pool: PgPool, + redis_url: &str, + relay_keys: Keys, + ) -> Arc { + let mut config = crate::config::Config::from_env().expect("default test config"); + config.redis_url = redis_url.to_owned(); + let redis_pool = deadpool_redis::Config::from_url(redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(redis_url, redis_pool.clone()) + .await + .expect("test 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); + 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("test media store"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + relay_keys, + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn projection_worker_retries_after_restart_and_fans_out_canonical_withdrawal() { + let (db, pool) = setup_db().await; + let community = make_community(&pool).await; + let relay_keys = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + db.bind_or_validate_identity( + community, + "https://provider.example", + "synthetic-subject", + subject.as_bytes(), + Some("Synthetic Label"), + SOURCE_DB_BINDING, + ) + .await + .expect("create synthetic binding"); + let active = build_identity_assertion( + &relay_keys, + subject, + Some("Synthetic Label"), + Timestamp::now().as_secs().saturating_add(300), + Timestamp::now(), + ) + .expect("build active projection"); + buzz_db::public_projection::begin_active_public_projection( + &db, + community, + relay_keys.public_key().as_bytes(), + "https://provider.example", + "synthetic-subject", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding exists") + .commit( + &active, + buzz_db::public_projection::ProjectionDisposition::Active, + ) + .await + .expect("commit active projection"); + db.revoke_identity_key(community, subject.as_bytes(), None, "synthetic revocation") + .await + .expect("revoke synthetic key"); + + let observational = make_community(&pool).await; + let observational_keys = Keys::generate(); + let observational_subject = observational_keys.public_key(); + db.bind_or_validate_identity( + observational, + "https://observational-provider.example", + "observational-subject", + observational_subject.as_bytes(), + Some("Observational Label"), + SOURCE_DB_BINDING, + ) + .await + .expect("create observational binding"); + let observational_active = build_identity_assertion( + &relay_keys, + observational_subject, + Some("Observational Label"), + Timestamp::now().as_secs().saturating_add(300), + Timestamp::now(), + ) + .expect("build observational projection"); + buzz_db::public_projection::begin_active_public_projection( + &db, + observational, + relay_keys.public_key().as_bytes(), + "https://observational-provider.example", + "observational-subject", + observational_subject.as_bytes(), + ) + .await + .expect("begin observational projection") + .expect("observational binding exists") + .commit( + &observational_active, + buzz_db::public_projection::ProjectionDisposition::Active, + ) + .await + .expect("commit observational projection"); + db.revoke_identity_key( + observational, + observational_subject.as_bytes(), + None, + "observational revocation", + ) + .await + .expect("revoke observational key"); + + let unavailable = projection_test_state( + db.clone(), + pool.clone(), + "redis://127.0.0.1:1", + relay_keys.clone(), + ) + .await; + assert!(matches!( + reconcile_public_projection_retirements_startup(&unavailable, &[community]).await, + Err(PublicProjectionReconciliationError::DeliveryUnavailable) + )); + assert_eq!( + buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[community], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("retryable retirement remains"), + 1 + ); + drop(unavailable); + + let live_redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); + let restarted = projection_test_state( + db.clone(), + pool.clone(), + &live_redis_url, + relay_keys.clone(), + ) + .await; + let tenant = restarted + .db + .usage_community_hosts() + .await + .expect("load synthetic tenant") + .into_iter() + .find(|entry| CommunityId::from_uuid(entry.id) == community) + .map(|entry| buzz_core::TenantContext::resolved(community, entry.host)) + .expect("synthetic tenant exists"); + let mut received = restarted.pubsub.subscribe_local(); + restarted + .pubsub + .retain_topic(&tenant, EventTopic::Global) + .await; + let subscriber_state = Arc::clone(&restarted.pubsub); + let subscriber = tokio::spawn(async move { subscriber_state.run_subscriber().await }); + tokio::time::sleep(Duration::from_millis(200)).await; + let worker_state = Arc::clone(&restarted); + let worker = tokio::spawn(async move { + run_public_projection_retirement_reconciliation(worker_state, vec![community]).await + }); + let withdrawal = tokio::time::timeout(Duration::from_secs(8), async { + loop { + let event = received + .recv() + .await + .expect("projection fan-out remains open"); + if event.community_id == community + && event.topic == EventTopic::Global + && event.event.kind.as_u16() as u32 == KIND_USER_TRUSTED_ASSERTION + { + break event.event; + } + } + }) + .await + .expect("restart retries and fans out the withdrawal"); + assert!(withdrawal.content.is_empty()); + assert!(identity_assertion_matches( + &withdrawal, + &subject.to_hex(), + None, + 0 + )); + let serialized_withdrawal = + serde_json::to_string(&withdrawal).expect("serialize withdrawal"); + assert!(!serialized_withdrawal.contains("provider.example")); + assert!(!serialized_withdrawal.contains("synthetic-subject")); + let mut completed = false; + for _ in 0..40 { + if buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[community], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("count completed retirement") + == 0 + { + completed = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + worker.abort(); + subscriber.abort(); + assert!( + completed, + "withdrawal delivery did not complete exactly once" + ); + let observational_head: String = sqlx::query_scalar( + "SELECT disposition FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(observational.as_uuid()) + .bind(relay_keys.public_key().as_bytes()) + .bind(observational_subject.as_bytes()) + .fetch_one(&pool) + .await + .expect("observational head remains"); + assert_eq!(observational_head, "active"); + assert_eq!( + buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[observational], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("observational mode has no retirement queue"), + 0 + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn delegation_requires_owner_identity_binding() { diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44e..ca53ab8a34 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -23,6 +23,19 @@ use axum::{ }; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use metrics_util::MetricKindMask; +use sha2::{Digest, Sha256}; + +/// Stable provider-neutral label that does not publish a tenant host. +/// +/// This label is for ephemeral runtime metrics only. Durable audit identity +/// and managed pseudonymization remain outside the metrics plane. +pub(crate) fn community_label(community_id: buzz_core::CommunityId) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-runtime-community-metric-v1"); + digest.update(community_id.as_uuid().as_bytes()); + let encoded = hex::encode(digest.finalize()); + encoded[..16].to_owned() +} /// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. const LATENCY_BUCKETS_MS: [f64; 11] = [ @@ -205,3 +218,18 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod privacy_tests { + use super::*; + + #[test] + fn community_metric_label_is_stable_bounded_and_opaque() { + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(0xfeed)); + let label = community_label(community); + assert_eq!(label, community_label(community)); + assert_eq!(label.len(), 16); + assert!(!label.contains(&community.to_string())); + assert!(label.bytes().all(|byte| byte.is_ascii_hexdigit())); + } +} From 7421262f71986ebbb228fa9ebfbed085aaa99e77 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:49:23 -0500 Subject: [PATCH 02/11] feat(auth): persist protected transport authority Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- Cargo.lock | 1 + crates/buzz-auth/src/blossom.rs | 212 ++ crates/buzz-auth/src/evidence_adapter.rs | 117 +- crates/buzz-auth/src/lib.rs | 2 + crates/buzz-auth/src/provider/mod.rs | 2 +- crates/buzz-db/src/audio_admission.rs | 1797 +++++++++++++ crates/buzz-db/src/dm.rs | 190 +- crates/buzz-db/src/event.rs | 693 ++++- crates/buzz-db/src/git_repo.rs | 246 +- crates/buzz-db/src/moderation.rs | 341 ++- crates/buzz-db/src/product_feedback.rs | 34 +- crates/buzz-db/src/protected_publication.rs | 873 ++++++ crates/buzz-db/src/protected_visibility.rs | 396 +++ crates/buzz-media/Cargo.toml | 1 + crates/buzz-media/src/auth.rs | 236 +- crates/buzz-media/src/lib.rs | 5 +- crates/buzz-media/src/storage.rs | 71 +- crates/buzz-media/src/upload.rs | 311 ++- crates/buzz-relay/src/api/git/cas_publish.rs | 57 + crates/buzz-relay/src/api/git/hook.rs | 9 + crates/buzz-relay/src/api/git/hydrate.rs | 73 +- crates/buzz-relay/src/api/git/migration.rs | 461 ++++ crates/buzz-relay/src/api/git/mod.rs | 1 + crates/buzz-relay/src/api/git/policy.rs | 30 +- crates/buzz-relay/src/api/git/transport.rs | 1164 ++++++-- crates/buzz-relay/src/api/media.rs | 913 +++++-- crates/buzz-relay/src/api/media_migration.rs | 880 +++++++ crates/buzz-relay/src/audio/handler.rs | 2257 +++++++++++++--- crates/buzz-relay/src/audio/join.rs | 2337 ++++++++++++++++- crates/buzz-relay/src/audio/mesh.rs | 713 ++++- crates/buzz-relay/src/audio/room.rs | 1970 +++++++++++++- .../0031_protected_object_publications.sql | 50 + migrations/0032_audio_session_admissions.sql | 22 + .../0033_protected_object_authority.sql | 28 + migrations/0034_git_publication_origin.sql | 7 + migrations/0035_audio_admission_lifecycle.sql | 39 + ...36_protected_community_lifecycle_guard.sql | 31 + .../0037_protected_domain_marker_guard.sql | 16 + migrations/0038_audio_cleanup_requests.sql | 11 + .../0039_git_policy_authority_epoch.sql | 18 + .../0040_audio_admission_visibility.sql | 57 + 41 files changed, 15498 insertions(+), 1174 deletions(-) create mode 100644 crates/buzz-auth/src/blossom.rs create mode 100644 crates/buzz-db/src/audio_admission.rs create mode 100644 crates/buzz-db/src/protected_publication.rs create mode 100644 crates/buzz-db/src/protected_visibility.rs create mode 100644 crates/buzz-relay/src/api/git/migration.rs create mode 100644 crates/buzz-relay/src/api/media_migration.rs create mode 100644 migrations/0031_protected_object_publications.sql create mode 100644 migrations/0032_audio_session_admissions.sql create mode 100644 migrations/0033_protected_object_authority.sql create mode 100644 migrations/0034_git_publication_origin.sql create mode 100644 migrations/0035_audio_admission_lifecycle.sql create mode 100644 migrations/0036_protected_community_lifecycle_guard.sql create mode 100644 migrations/0037_protected_domain_marker_guard.sql create mode 100644 migrations/0038_audio_cleanup_requests.sql create mode 100644 migrations/0039_git_policy_authority_epoch.sql create mode 100644 migrations/0040_audio_admission_visibility.sql diff --git a/Cargo.lock b/Cargo.lock index d1b2474692..63ed5de962 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,6 +1056,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-auth", "buzz-core", "bytes", "chrono", diff --git a/crates/buzz-auth/src/blossom.rs b/crates/buzz-auth/src/blossom.rs new file mode 100644 index 0000000000..6c72abf4b9 --- /dev/null +++ b/crates/buzz-auth/src/blossom.rs @@ -0,0 +1,212 @@ +//! Blossom kind:24242 authentication verification (BUD-11 compliant). + +/// Blossom kind:24242 verbs Buzz currently accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomVerb { + /// Authorize one blob upload. + Upload, + /// Authorize one blob download or a server-scoped download. + Get, +} + +impl BlossomVerb { + fn as_str(self) -> &'static str { + match self { + Self::Upload => "upload", + Self::Get => "get", + } + } +} + +/// Rejection from full Blossom operation verification. +#[derive(Debug, thiserror::Error)] +pub enum BlossomAuthError { + /// The Schnorr signature is invalid. + #[error("invalid signature")] + InvalidSignature, + /// The event is not kind 24242. + #[error("invalid auth event kind")] + InvalidAuthKind, + /// The event does not contain a human-readable description. + #[error("invalid auth event")] + InvalidAuthEvent, + /// The `t` tag does not name the required operation. + #[error("invalid auth verb")] + InvalidAuthVerb, + /// A required tag is absent. + #[error("missing required tag: {0}")] + MissingTag(&'static str), + /// The event has expired. + #[error("token expired")] + TokenExpired, + /// The event creation time is outside the accepted replay window. + #[error("timestamp out of window")] + TimestampOutOfWindow, + /// A server tag does not match the request-bound host. + #[error("server mismatch")] + ServerMismatch, + /// No `x` tag matches the exact blob hash. + #[error("hash mismatch")] + HashMismatch, + /// The event does not authorize the requested blob or server. + #[error("insufficient scope")] + InsufficientScope, +} + +/// Verify common kind:24242 Blossom event validity for one exact verb. +/// +/// This verifies the signature, kind, non-empty content, verb, expiration, +/// creation-time replay window, and any request-bound server tags. It does not +/// check verb-specific blob scope. +pub fn verify_blossom_auth_event_for_verb( + auth_event: &nostr::Event, + verb: BlossomVerb, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), BlossomAuthError> { + auth_event + .verify() + .map_err(|_| BlossomAuthError::InvalidSignature)?; + + if auth_event.kind.as_u16() != 24242 { + return Err(BlossomAuthError::InvalidAuthKind); + } + if auth_event.content.trim().is_empty() { + return Err(BlossomAuthError::InvalidAuthEvent); + } + + let mut found_t = false; + let mut found_exp = false; + let mut server_tags: Vec<&str> = Vec::new(); + let mut exp_value: u64 = 0; + + for tag in auth_event.tags.iter() { + match tag.kind().to_string().as_str() { + "t" => { + if let Some(value) = tag.content() { + if value != verb.as_str() { + return Err(BlossomAuthError::InvalidAuthVerb); + } + found_t = true; + } + } + "expiration" => { + if let Some(value) = tag.content() { + exp_value = value.parse().unwrap_or(0); + found_exp = true; + } + } + "server" => { + if let Some(value) = tag.content() { + server_tags.push(value); + } + } + _ => {} + } + } + + if !found_t { + return Err(BlossomAuthError::MissingTag("t")); + } + if !found_exp { + return Err(BlossomAuthError::MissingTag("expiration")); + } + + let now = nostr::Timestamp::now().as_secs(); + if exp_value <= now { + return Err(BlossomAuthError::TokenExpired); + } + + let created = auth_event.created_at.as_secs(); + if created > now + 5 || now > created + max_age_secs { + return Err(BlossomAuthError::TimestampOutOfWindow); + } + + if !server_tags.is_empty() { + let Some(domain) = server_domain else { + return Err(BlossomAuthError::ServerMismatch); + }; + let expected = normalize_server_host(domain); + if !server_tags + .iter() + .any(|tag| normalize_server_host(tag) == expected) + { + return Err(BlossomAuthError::ServerMismatch); + } + } + + Ok(()) +} + +/// Verify common upload auth event validity without checking the blob hash. +pub fn verify_blossom_auth_event( + auth_event: &nostr::Event, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), BlossomAuthError> { + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs) +} + +/// Verify a kind:24242 upload event including the exact `x` tag blob hash. +pub fn verify_blossom_upload_auth( + auth_event: &nostr::Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), BlossomAuthError> { + verify_blossom_auth_event_for_verb( + auth_event, + BlossomVerb::Upload, + server_domain, + max_age_secs, + )?; + + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256)); + if !has_matching_x { + return Err(BlossomAuthError::HashMismatch); + } + Ok(()) +} + +/// Verify a kind:24242 download event for one exact blob and server. +/// +/// BUD-01 permits either an `x` tag matching `sha256` or a matching `server` +/// tag. Callers must still enforce relay membership after this verifier. +pub fn verify_blossom_get_auth( + auth_event: &nostr::Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), BlossomAuthError> { + verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?; + + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256)); + let has_matching_server = server_domain.is_some_and(|domain| { + let expected = normalize_server_host(domain); + auth_event.tags.iter().any(|tag| { + tag.kind().to_string() == "server" + && tag + .content() + .is_some_and(|value| normalize_server_host(value) == expected) + }) + }); + + if !has_matching_x && !has_matching_server { + return Err(BlossomAuthError::InsufficientScope); + } + Ok(()) +} + +fn normalize_server_host(value: &str) -> String { + let authority = match value.split_once("://") { + Some((_scheme, rest)) => rest.split('/').next().unwrap_or(rest), + None => value.split('/').next().unwrap_or(value), + }; + buzz_core::tenant::normalize_host(authority) +} diff --git a/crates/buzz-auth/src/evidence_adapter.rs b/crates/buzz-auth/src/evidence_adapter.rs index 5346053e44..4f39063379 100644 --- a/crates/buzz-auth/src/evidence_adapter.rs +++ b/crates/buzz-auth/src/evidence_adapter.rs @@ -197,6 +197,82 @@ impl VerifiedEvidenceAdapter { .map_err(Into::into) } + /// Fully verify and bind one exact Blossom upload operation. + pub fn verify_blossom_upload( + &self, + authorization_domain: CommunityId, + event: &Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, + ) -> Result { + crate::blossom::verify_blossom_upload_auth(event, sha256, server_domain, max_age_secs)?; + let event_id = event.id.to_bytes(); + let max_age = max_age_secs.to_be_bytes(); + let binding = operation_binding( + VerifiedOperationBindingKind::BlossomUpload, + &[ + &event_id, + sha256.as_bytes(), + server_domain.unwrap_or_default().as_bytes(), + &max_age, + ], + ); + self.blossom_proof( + authorization_domain, + AuthTransport::MediaUpload, + event, + binding, + ) + } + + /// Fully verify and bind one exact Blossom GET or HEAD operation. + pub fn verify_blossom_download( + &self, + authorization_domain: CommunityId, + event: &Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, + ) -> Result { + crate::blossom::verify_blossom_get_auth(event, sha256, server_domain, max_age_secs)?; + let event_id = event.id.to_bytes(); + let max_age = max_age_secs.to_be_bytes(); + let binding = operation_binding( + VerifiedOperationBindingKind::BlossomDownload, + &[ + &event_id, + sha256.as_bytes(), + server_domain.unwrap_or_default().as_bytes(), + &max_age, + ], + ); + self.blossom_proof( + authorization_domain, + AuthTransport::MediaDownload, + event, + binding, + ) + } + + fn blossom_proof( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + event: &Event, + binding: VerifiedOperationBinding, + ) -> Result { + VerifiedNostrProof::from_evidence_adapter( + authorization_domain, + transport, + event.pubkey, + AuthMethod::Blossom, + binding, + None, + ) + .map_err(Into::into) + } + fn delegation( &self, actor: PublicKey, @@ -392,6 +468,9 @@ pub enum EvidenceAdapterError { /// Existing cryptographic proof failed. #[error(transparent)] Authentication(#[from] crate::AuthError), + /// Existing full Blossom operation verification failed. + #[error(transparent)] + Blossom(#[from] crate::blossom::BlossomAuthError), /// Sealed context evidence was inconsistent. #[error(transparent)] Context(#[from] AuthContextError), @@ -438,7 +517,7 @@ pub enum EvidenceAdapterError { #[cfg(test)] mod tests { - use nostr::{EventBuilder, Keys, RelayUrl}; + use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag, Timestamp}; use super::*; @@ -673,4 +752,40 @@ mod tests { Err(EvidenceAdapterError::InvalidBindingResolution) )); } + + #[test] + fn blossom_factories_reverify_exact_hash_verb_and_server() { + let adapter = VerifiedEvidenceAdapter::new(); + let expiration = (Timestamp::now().as_secs() + 300).to_string(); + let upload_hash = "a".repeat(64); + let substituted_hash = "b".repeat(64); + let upload = EventBuilder::new(Kind::from(24_242), "upload") + .tags([ + Tag::parse(["t", "upload"]).expect("verb"), + Tag::parse(["x", &upload_hash]).expect("hash"), + Tag::parse(["server", "relay.example"]).expect("server"), + Tag::parse(["expiration", &expiration]).expect("expiration"), + ]) + .sign_with_keys(&Keys::generate()) + .expect("event"); + + assert!(adapter + .verify_blossom_upload(domain(1), &upload, &upload_hash, Some("relay.example"), 600,) + .is_ok()); + assert!(adapter + .verify_blossom_upload( + domain(1), + &upload, + &substituted_hash, + Some("relay.example"), + 600, + ) + .is_err()); + assert!(adapter + .verify_blossom_download(domain(1), &upload, &upload_hash, Some("relay.example"), 600,) + .is_err()); + assert!(adapter + .verify_blossom_upload(domain(1), &upload, &upload_hash, Some("other.example"), 600,) + .is_err()); + } } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index db12a82428..38433c880b 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,6 +17,8 @@ /// Channel access checking trait and helpers. pub mod access; +/// Complete Blossom operation authentication verification. +pub mod blossom; /// Versioned, transport-neutral authorization context. pub mod context; /// Authentication error types. diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index ce65bc1636..4db5d56d21 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -1246,7 +1246,7 @@ impl CapabilitySnapshot { { return Err(ProviderContractError::CapabilityBindingChanged.into()); } - let admission = VerifiedOwnerAdmission::new( + let admission = VerifiedOwnerAdmission::from_capability_snapshot( self.authorization_domain, self.principal, AdmissionExpiry::new(self.effective_until)?, diff --git a/crates/buzz-db/src/audio_admission.rs b/crates/buzz-db/src/audio_admission.rs new file mode 100644 index 0000000000..5ba50fca8c --- /dev/null +++ b/crates/buzz-db/src/audio_admission.rs @@ -0,0 +1,1797 @@ +//! Transaction-owned audio admission for an existing channel member. + +use buzz_core::CommunityId; +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use crate::{DbError, Result}; + +/// Commit an expiring audio admission inside the authorization transaction. +/// +/// The channel and membership rows are locked and rechecked immediately before +/// insertion. This function never creates membership. +pub async fn admit_existing_audio_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + admission_id: Uuid, + channel_id: Uuid, + pubkey: &[u8; 32], + claimant_id: Uuid, + lease_expires_at: u64, +) -> Result<()> { + if claimant_id.is_nil() { + return Err(DbError::InvalidData( + "audio attachment claimant must be non-nil".into(), + )); + } + crate::channel::acquire_channel_membership_lock(transaction, community_id, channel_id).await?; + let channel_active: Option = sqlx::query_scalar( + "SELECT 1 FROM channels \ + WHERE community_id = $1 AND id = $2 \ + AND archived_at IS NULL AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **transaction) + .await?; + if channel_active.is_none() { + return Err(DbError::InvalidData("audio channel is unavailable".into())); + } + + let membership_active: Option = sqlx::query_scalar( + "SELECT 1 FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey.as_slice()) + .fetch_optional(&mut **transaction) + .await?; + if membership_active.is_none() { + return Err(DbError::InvalidData( + "audio admission requires existing membership".into(), + )); + } + + let inserted = sqlx::query( + "INSERT INTO audio_session_admissions \ + (community_id, admission_id, channel_id, pubkey, lease_expires_at, state, \ + claimant_id, claim_expires_at) \ + VALUES ($1, $2, $3, $4, to_timestamp($5::double precision), 'reserved', \ + $6, to_timestamp($5::double precision)) \ + ON CONFLICT (community_id, admission_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(channel_id) + .bind(pubkey.as_slice()) + .bind(lease_expires_at as f64) + .bind(claimant_id) + .execute(&mut **transaction) + .await? + .rows_affected(); + if inserted == 0 { + let exact: Option = sqlx::query_scalar( + "SELECT channel_id = $3 AND pubkey = $4 AND claimant_id = $6 AND \ + lease_expires_at = to_timestamp($5::double precision) AND \ + state IN ('reserved', 'active', 'visible') \ + FROM audio_session_admissions \ + WHERE community_id = $1 AND admission_id = $2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(channel_id) + .bind(pubkey.as_slice()) + .bind(lease_expires_at as f64) + .bind(claimant_id) + .fetch_optional(&mut **transaction) + .await?; + if exact != Some(true) { + return Err(DbError::InvalidData( + "audio admission retry conflicts with durable lifecycle".into(), + )); + } + } + Ok(()) +} + +/// Activate one reserved attempt inside the caller's authorization-owned +/// transaction immediately before any peer-visible effect. +pub async fn activate_audio_admission_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + admission_id: Uuid, + channel_id: Uuid, + pubkey: &[u8; 32], + claimant_id: Uuid, + lease_expires_at: u64, +) -> Result<()> { + if claimant_id.is_nil() { + return Err(DbError::InvalidData( + "audio attachment claimant must be non-nil".into(), + )); + } + crate::channel::acquire_channel_membership_lock(transaction, community_id, channel_id).await?; + let state: Option<(String, Option)> = sqlx::query_as( + "SELECT state, claimant_id FROM audio_session_admissions \ + WHERE community_id = $1 AND admission_id = $2 \ + AND channel_id = $3 AND pubkey = $4 \ + AND lease_expires_at = to_timestamp($5::double precision) \ + FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(channel_id) + .bind(pubkey.as_slice()) + .bind(lease_expires_at as f64) + .fetch_optional(&mut **transaction) + .await?; + match state { + Some((state, Some(existing))) if state == "reserved" && existing == claimant_id => { + let changed = sqlx::query( + "UPDATE audio_session_admissions \ + SET state='active', state_version=state_version+1, \ + activated_at=COALESCE(activated_at, clock_timestamp()), \ + claimant_id=$3, attachment_generation=1, \ + claim_expires_at=lease_expires_at, \ + updated_at=clock_timestamp(), failure_code=NULL \ + WHERE community_id=$1 AND admission_id=$2 \ + AND state='reserved' \ + AND lease_expires_at > clock_timestamp() \ + AND EXISTS ( \ + SELECT 1 FROM channel_members cm \ + WHERE cm.community_id=audio_session_admissions.community_id \ + AND cm.channel_id=audio_session_admissions.channel_id \ + AND cm.pubkey=audio_session_admissions.pubkey \ + AND cm.removed_at IS NULL) \ + AND EXISTS ( \ + SELECT 1 FROM channels c \ + WHERE c.community_id=audio_session_admissions.community_id \ + AND c.id=audio_session_admissions.channel_id \ + AND c.archived_at IS NULL AND c.deleted_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(claimant_id) + .execute(&mut **transaction) + .await? + .rows_affected(); + if changed != 1 { + return Err(DbError::InvalidData( + "audio admission expired or lost authority before activation".into(), + )); + } + } + Some((state, Some(existing))) + if matches!(state.as_str(), "active" | "visible") && existing == claimant_id => {} + _ => { + return Err(DbError::InvalidData( + "audio admission is not activatable".into(), + )) + } + } + Ok(()) +} + +/// Whether an already-replayed activation still represents this exact live +/// attempt. Terminal receipts cannot be reused to create a second attachment. +pub async fn audio_admission_is_active( + db: &crate::Db, + community_id: CommunityId, + admission_id: Uuid, + channel_id: Uuid, + pubkey: &[u8; 32], + claimant_id: Uuid, +) -> Result { + let active: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2 AND channel_id=$3 \ + AND pubkey=$4 AND claimant_id=$5 AND attachment_generation=1 \ + AND state IN ('active','visible') AND lease_expires_at > clock_timestamp() \ + AND claim_expires_at > clock_timestamp() \ + AND EXISTS (SELECT 1 FROM channel_members cm \ + WHERE cm.community_id=audio_session_admissions.community_id \ + AND cm.channel_id=audio_session_admissions.channel_id \ + AND cm.pubkey=audio_session_admissions.pubkey \ + AND cm.removed_at IS NULL))", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(channel_id) + .bind(pubkey.as_slice()) + .bind(claimant_id) + .fetch_one(&db.pool) + .await?; + Ok(active) +} + +/// Durably record that one exact authorized attempt became peer-visible. +/// +/// This is evidence that visibility occurred once, not evidence of current +/// live presence. The operation receipt and state transition commit together +/// so independent restore protection can recover an interrupted witness. +#[allow(clippy::too_many_arguments)] +pub async fn mark_audio_admission_visible_with_receipt( + db: &crate::Db, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + operation_id: Uuid, + request_fingerprint: [u8; 32], +) -> Result<()> { + if claimant_id.is_nil() || operation_id.is_nil() { + return Err(DbError::InvalidData( + "audio visibility identity must be non-nil".into(), + )); + } + let mut tx = db.pool.begin().await?; + let existing: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + if existing.as_slice() != request_fingerprint { + return Err(DbError::InvalidData( + "audio visibility operation ID conflicts with prior request".into(), + )); + } + tx.commit().await?; + return Ok(()); + } + + let current: Option<(String, Option)> = sqlx::query_as( + "SELECT state, claimant_id FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_optional(&mut *tx) + .await?; + match current { + Some((state, existing_claimant)) + if state == "active" && existing_claimant == Some(claimant_id) => + { + let changed = sqlx::query( + "UPDATE audio_session_admissions SET \ + state='visible', state_version=state_version+1, \ + visibility_observed_at=COALESCE(visibility_observed_at, clock_timestamp()), \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND admission_id=$2 AND state='active' \ + AND claimant_id=$3 AND lease_expires_at > clock_timestamp() \ + AND claim_expires_at > clock_timestamp() \ + AND EXISTS (SELECT 1 FROM channel_members cm \ + WHERE cm.community_id=audio_session_admissions.community_id \ + AND cm.channel_id=audio_session_admissions.channel_id \ + AND cm.pubkey=audio_session_admissions.pubkey \ + AND cm.removed_at IS NULL) \ + AND EXISTS (SELECT 1 FROM channels c \ + WHERE c.community_id=audio_session_admissions.community_id \ + AND c.id=audio_session_admissions.channel_id \ + AND c.archived_at IS NULL AND c.deleted_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(claimant_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed != 1 { + return Err(DbError::InvalidData( + "audio admission expired or lost authority before visibility".into(), + )); + } + } + Some((state, existing_claimant)) + if state == "visible" && existing_claimant == Some(claimant_id) => {} + _ => { + return Err(DbError::InvalidData( + "audio admission is not visibility-confirmable".into(), + )) + } + } + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_payload, lease_expires_at) \ + VALUES ($1,$2,'audio.admission.visible.v1',$3,$4, \ + clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(b"visible".as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Durably record that an exact live attempt is being detached. +/// +/// The owning caller independently witnesses this transaction before its +/// direct compensation. Other replicas still respect the claimant's durable +/// deadline and cleanup grace before orphan takeover. +pub async fn request_audio_admission_cleanup_with_receipt( + db: &crate::Db, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + operation_id: Uuid, + request_fingerprint: [u8; 32], +) -> Result<()> { + if claimant_id.is_nil() || operation_id.is_nil() { + return Err(DbError::InvalidData( + "audio cleanup identity must be non-nil".into(), + )); + } + let mut tx = db.pool.begin().await?; + let existing: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + if existing.as_slice() != request_fingerprint { + return Err(DbError::InvalidData( + "audio cleanup operation ID conflicts with prior request".into(), + )); + } + tx.commit().await?; + return Ok(()); + } + + let current: Option<(String, Option)> = sqlx::query_as( + "SELECT state, claimant_id FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_optional(&mut *tx) + .await?; + match current { + Some((state, existing_claimant)) + if existing_claimant == Some(claimant_id) + && matches!(state.as_str(), "reserved" | "active" | "visible") => + { + sqlx::query( + "UPDATE audio_session_admissions SET \ + cleanup_requested_at=COALESCE(cleanup_requested_at, clock_timestamp()), \ + state_version=state_version+1, updated_at=clock_timestamp() \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&mut *tx) + .await?; + } + Some((state, existing_claimant)) + if existing_claimant == Some(claimant_id) + && matches!(state.as_str(), "aborted" | "finished") => {} + _ => { + return Err(DbError::InvalidData( + "audio cleanup request conflicts with durable lifecycle".into(), + )); + } + } + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_payload, lease_expires_at) \ + VALUES ($1,$2,'audio.admission.cleanup-request.v1',$3,$4, \ + clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(b"cleanup_requested".as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Atomically finish or compensate an exact attachment and retain the restore +/// operation receipt needed to recover a crash between PostgreSQL and the +/// independent witness commit. +#[allow(clippy::too_many_arguments)] +pub async fn complete_claimed_audio_admission_with_receipt( + db: &crate::Db, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + finished: bool, + failure_code: Option<&str>, + operation_id: Uuid, + request_fingerprint: [u8; 32], +) -> Result<()> { + complete_claimed_audio_admission_inner( + db, + community_id, + admission_id, + claimant_id, + finished, + failure_code, + operation_id, + request_fingerprint, + None, + ) + .await + .map(|_| ()) +} + +/// Reconcile only the exact crash-remnant version discovered by the caller. +/// +/// `Ok(false)` means another transaction advanced the lifecycle first. The +/// caller must abort its pending restore witness and must not downgrade the +/// newer state. +#[allow(clippy::too_many_arguments)] +pub async fn reconcile_claimed_audio_admission_with_receipt( + db: &crate::Db, + community_id: CommunityId, + candidate: AudioAdmissionReconciliationCandidate, + finished: bool, + failure_code: Option<&str>, + operation_id: Uuid, + request_fingerprint: [u8; 32], +) -> Result { + complete_claimed_audio_admission_inner( + db, + community_id, + candidate.admission_id, + candidate.claimant_id, + finished, + failure_code, + operation_id, + request_fingerprint, + Some((candidate.source_state.as_str(), candidate.state_version)), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn complete_claimed_audio_admission_inner( + db: &crate::Db, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + finished: bool, + failure_code: Option<&str>, + operation_id: Uuid, + request_fingerprint: [u8; 32], + expected_source: Option<(&str, i64)>, +) -> Result { + if claimant_id.is_nil() || operation_id.is_nil() { + return Err(DbError::InvalidData( + "audio completion identity must be non-nil".into(), + )); + } + if finished { + if failure_code.is_some() { + return Err(DbError::InvalidData( + "finished audio completion cannot carry a failure code".into(), + )); + } + } else { + validate_failure_code(failure_code.ok_or_else(|| { + DbError::InvalidData("aborted audio completion requires a failure code".into()) + })?)?; + } + let mut tx = db.pool.begin().await?; + let target = if finished { "finished" } else { "aborted" }; + let current: Option<(String, Option, i64)> = sqlx::query_as( + "SELECT state, claimant_id, state_version FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_optional(&mut *tx) + .await?; + if let (Some((expected_state, expected_version)), Some((state, _, state_version))) = + (expected_source, current.as_ref()) + { + if state != target && (state != expected_state || *state_version != expected_version) { + tx.rollback().await?; + return Ok(false); + } + } + match current { + Some((state, existing_claimant, _)) if state == target => { + if existing_claimant.is_some() && existing_claimant != Some(claimant_id) { + return Err(DbError::InvalidData( + "audio completion claimant conflicts with durable state".into(), + )); + } + } + Some((state, existing_claimant, _)) + if ((!finished && matches!(state.as_str(), "reserved" | "active" | "visible")) + || (finished && state == "visible")) + && existing_claimant == Some(claimant_id) => + { + let updated = sqlx::query( + "UPDATE audio_session_admissions \ + SET state=$3, state_version=state_version+1, \ + finished_at=CASE WHEN $3='finished' THEN \ + COALESCE(finished_at, clock_timestamp()) ELSE finished_at END, \ + aborted_at=CASE WHEN $3='aborted' THEN \ + COALESCE(aborted_at, clock_timestamp()) ELSE aborted_at END, \ + updated_at=clock_timestamp(), failure_code=$4 \ + WHERE community_id=$1 AND admission_id=$2 \ + AND ($3 <> 'finished' OR \ + (lease_expires_at > clock_timestamp() \ + AND claim_expires_at > clock_timestamp()))", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .bind(target) + .bind(failure_code) + .execute(&mut *tx) + .await?; + if updated.rows_affected() != 1 { + return Err(DbError::InvalidData( + "expired audio admission cannot become finished".into(), + )); + } + } + _ => { + return Err(DbError::InvalidData( + "audio completion is stale or conflicts with durable state".into(), + )) + } + } + let existing: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(existing) = existing { + if existing.as_slice() != request_fingerprint { + return Err(DbError::InvalidData( + "audio completion operation ID conflicts with prior request".into(), + )); + } + } else { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_payload, lease_expires_at) \ + VALUES ($1, $2, 'audio.admission.complete.v1', $3, $4, \ + clock_timestamp() + INTERVAL '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(target.as_bytes()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(true) +} + +/// Nonterminal durable state observed by reconciliation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AudioAdmissionReconciliationState { + /// Membership was authorized, but attachment activation did not commit. + Reserved, + /// Activation committed, but peer visibility was not durably observed. + Active, + /// Peer visibility was durably observed at least once. + Visible, +} + +impl AudioAdmissionReconciliationState { + /// Stable database representation used by exact compare-and-set cleanup. + pub const fn as_str(self) -> &'static str { + match self { + Self::Reserved => "reserved", + Self::Active => "active", + Self::Visible => "visible", + } + } + + /// Only a durably observed attachment may be recorded as finished. + pub const fn visibility_was_observed(self) -> bool { + matches!(self, Self::Visible) + } + + /// Stable terminal failure for a conservatively aborted attempt. + pub const fn abort_failure_code(self) -> Option<&'static str> { + match self { + Self::Reserved => Some("stale_reservation"), + Self::Active => Some("unobserved_attachment"), + Self::Visible => None, + } + } + + fn parse(value: &str) -> Result { + match value { + "reserved" => Ok(Self::Reserved), + "active" => Ok(Self::Active), + "visible" => Ok(Self::Visible), + _ => Err(DbError::InvalidData( + "audio reconciliation state is invalid".into(), + )), + } + } +} + +/// One crash remnant that requires a restore-witnessed terminal transition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AudioAdmissionReconciliationCandidate { + /// Stable attempt identifier. + pub admission_id: Uuid, + /// Exact process attachment claimant recorded at reservation. + pub claimant_id: Uuid, + /// Exact nonterminal state observed by discovery. + pub source_state: AudioAdmissionReconciliationState, + /// Exact lifecycle version observed with `source_state`. + pub state_version: i64, +} + +/// Discover crash remnants without mutating authority state. The relay owns +/// each terminal transition so it can witness the PostgreSQL commit in the +/// independent restore-protection store. +pub async fn reconcilable_audio_admissions( + db: &crate::Db, + community_id: CommunityId, +) -> Result> { + audio_admissions_requiring_reconciliation_after(db, community_id, None).await +} + +/// Discover orphaned nonterminal attempts during startup. +/// +/// An unexpired claim may belong to another healthy replica and is never a +/// takeover candidate. The durable claim deadline plus cleanup grace is the +/// bounded liveness proof required before another replica may compensate it. +pub async fn unfinished_audio_admissions( + db: &crate::Db, + community_id: CommunityId, +) -> Result> { + unfinished_audio_admissions_after(db, community_id, None).await +} + +/// Read one deterministic reconciliation page strictly after an admission ID. +/// Cursor pagination prevents one persistent poisoned prefix from starving +/// later cleanup candidates. +pub async fn reconcilable_audio_admissions_after( + db: &crate::Db, + community_id: CommunityId, + after_admission_id: Option, +) -> Result> { + audio_admissions_requiring_reconciliation_after(db, community_id, after_admission_id).await +} + +/// Read one startup-reconciliation page after an admission ID. +pub async fn unfinished_audio_admissions_after( + db: &crate::Db, + community_id: CommunityId, + after_admission_id: Option, +) -> Result> { + audio_admissions_requiring_reconciliation_after(db, community_id, after_admission_id).await +} + +async fn audio_admissions_requiring_reconciliation_after( + db: &crate::Db, + community_id: CommunityId, + after_admission_id: Option, +) -> Result> { + let rows: Vec<(Uuid, Uuid, String, i64)> = sqlx::query_as( + "SELECT admission_id, claimant_id, state, state_version \ + FROM audio_session_admissions \ + WHERE community_id=$1 \ + AND ($2::uuid IS NULL OR admission_id > $2) \ + AND claimant_id IS NOT NULL \ + AND state IN ('reserved','active','visible') \ + AND claim_expires_at <= clock_timestamp() - interval '30 seconds' \ + ORDER BY admission_id LIMIT 256", + ) + .bind(community_id.as_uuid()) + .bind(after_admission_id) + .fetch_all(&db.pool) + .await?; + rows.into_iter() + .map(|(admission_id, claimant_id, state, state_version)| { + Ok(AudioAdmissionReconciliationCandidate { + admission_id, + claimant_id, + source_state: AudioAdmissionReconciliationState::parse(&state)?, + state_version, + }) + }) + .collect() +} + +fn validate_failure_code(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(DbError::InvalidData( + "audio admission failure code is invalid".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel::{ChannelType, ChannelVisibility}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup() -> (crate::Db, CommunityId, Uuid, [u8; 32]) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("test migrations"); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id.as_uuid()) + .bind(format!( + "audio-admission-{}.example", + Uuid::new_v4().simple() + )) + .execute(&pool) + .await + .expect("test community"); + let member = [4_u8; 32]; + let channel_id = Uuid::new_v4(); + crate::channel::create_channel_with_id( + &pool, + community_id, + channel_id, + "Audio admission", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &member, + None, + ) + .await + .expect("test channel"); + (crate::Db::from_pool(pool), community_id, channel_id, member) + } + + fn epoch_after(seconds: u64) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_secs() + + seconds + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn admission_requires_existing_member_and_never_creates_membership() { + let (db, community_id, channel_id, member) = setup().await; + let mut admitted = db.begin_transaction().await.expect("transaction"); + admit_existing_audio_member_tx( + &mut admitted, + community_id, + Uuid::new_v4(), + channel_id, + &member, + Uuid::from_u128(0x1000), + epoch_after(60), + ) + .await + .expect("existing member admission"); + admitted.commit().await.expect("commit admission"); + + let outsider = [8_u8; 32]; + let mut denied = db.begin_transaction().await.expect("transaction"); + assert!(admit_existing_audio_member_tx( + &mut denied, + community_id, + Uuid::new_v4(), + channel_id, + &outsider, + Uuid::from_u128(0x1001), + epoch_after(60), + ) + .await + .is_err()); + denied.rollback().await.expect("rollback denial"); + + let membership_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(outsider.as_slice()) + .fetch_one(&db.pool) + .await + .expect("membership count"); + assert_eq!(membership_count, 0); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn lifecycle_is_idempotent_and_terminal() { + let (db, community_id, channel_id, member) = setup().await; + let admission_id = Uuid::new_v4(); + let lease_expires_at = epoch_after(60); + let mut transaction = db.begin_transaction().await.expect("transaction"); + admit_existing_audio_member_tx( + &mut transaction, + community_id, + admission_id, + channel_id, + &member, + Uuid::from_u128(0x1001), + lease_expires_at, + ) + .await + .expect("reserve"); + transaction.commit().await.expect("commit reserve"); + + let mut activation = db.begin_transaction().await.expect("activation tx"); + activate_audio_admission_tx( + &mut activation, + community_id, + admission_id, + channel_id, + &member, + Uuid::from_u128(0x1001), + lease_expires_at, + ) + .await + .expect("activate"); + activation.commit().await.expect("commit activation"); + let cleanup_id = Uuid::new_v4(); + request_audio_admission_cleanup_with_receipt( + &db, + community_id, + admission_id, + Uuid::from_u128(0x1001), + cleanup_id, + [5_u8; 32], + ) + .await + .expect("request cleanup"); + request_audio_admission_cleanup_with_receipt( + &db, + community_id, + admission_id, + Uuid::from_u128(0x1001), + cleanup_id, + [5_u8; 32], + ) + .await + .expect("cleanup request replay"); + assert!(reconcilable_audio_admissions(&db, community_id) + .await + .expect("healthy claimant remains exclusive") + .is_empty()); + sqlx::query( + "UPDATE audio_session_admissions \ + SET claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .expect("expire durable claimant"); + assert_eq!( + reconcilable_audio_admissions(&db, community_id) + .await + .expect("expired claimant is discoverable"), + vec![AudioAdmissionReconciliationCandidate { + admission_id, + claimant_id: Uuid::from_u128(0x1001), + source_state: AudioAdmissionReconciliationState::Active, + state_version: 3, + }] + ); + let completion_id = Uuid::new_v4(); + complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + Uuid::from_u128(0x1001), + false, + Some("cancelled"), + completion_id, + [6_u8; 32], + ) + .await + .expect("compensate"); + complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + Uuid::from_u128(0x1001), + false, + Some("cancelled"), + completion_id, + [6_u8; 32], + ) + .await + .expect("idempotent compensate"); + let mut terminal = db.begin_transaction().await.expect("terminal tx"); + assert!(activate_audio_admission_tx( + &mut terminal, + community_id, + admission_id, + channel_id, + &member, + Uuid::from_u128(0x1001), + lease_expires_at, + ) + .await + .is_err()); + terminal.rollback().await.expect("rollback terminal check"); + + let state: String = sqlx::query_scalar( + "SELECT state FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_one(&db.pool) + .await + .expect("state"); + assert_eq!(state, "aborted"); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn exact_claimant_and_completion_receipt_prevent_replay() { + let (db, community_id, channel_id, member) = setup().await; + let admission_id = Uuid::new_v4(); + let claimant = Uuid::new_v4(); + let other_claimant = Uuid::new_v4(); + let lease_expires_at = epoch_after(60); + + let mut reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut reserve, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("reserve exact claimant"); + reserve.commit().await.expect("commit reserve"); + + let mut conflicting_reserve = db.begin_transaction().await.expect("conflicting reserve"); + assert!(admit_existing_audio_member_tx( + &mut conflicting_reserve, + community_id, + admission_id, + channel_id, + &member, + other_claimant, + lease_expires_at, + ) + .await + .is_err()); + conflicting_reserve + .rollback() + .await + .expect("rollback claimant conflict"); + + let mut activate = db.begin_transaction().await.expect("activate tx"); + activate_audio_admission_tx( + &mut activate, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("activate exact claimant"); + activate.commit().await.expect("commit activation"); + assert!(audio_admission_is_active( + &db, + community_id, + admission_id, + channel_id, + &member, + claimant, + ) + .await + .expect("exact claimant state")); + assert!(!audio_admission_is_active( + &db, + community_id, + admission_id, + channel_id, + &member, + other_claimant, + ) + .await + .expect("other claimant state")); + assert!(unfinished_audio_admissions(&db, community_id) + .await + .expect("startup preserves a healthy active claimant") + .into_iter() + .all(|candidate| candidate.admission_id != admission_id)); + + assert!(complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + claimant, + true, + None, + Uuid::new_v4(), + [7_u8; 32], + ) + .await + .is_err()); + assert!(sqlx::query( + "UPDATE audio_session_admissions SET state='finished' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .is_err()); + + let visibility_operation = Uuid::new_v4(); + let visibility_request = [8_u8; 32]; + mark_audio_admission_visible_with_receipt( + &db, + community_id, + admission_id, + claimant, + visibility_operation, + visibility_request, + ) + .await + .expect("mark exact attachment visible"); + mark_audio_admission_visible_with_receipt( + &db, + community_id, + admission_id, + claimant, + visibility_operation, + visibility_request, + ) + .await + .expect("visibility receipt replay"); + assert!(mark_audio_admission_visible_with_receipt( + &db, + community_id, + admission_id, + claimant, + visibility_operation, + [11_u8; 32], + ) + .await + .is_err()); + assert!(mark_audio_admission_visible_with_receipt( + &db, + community_id, + admission_id, + other_claimant, + Uuid::new_v4(), + [12_u8; 32], + ) + .await + .is_err()); + assert!(unfinished_audio_admissions(&db, community_id) + .await + .expect("startup preserves a healthy visible claimant") + .into_iter() + .all(|candidate| candidate.admission_id != admission_id)); + + let operation_id = Uuid::new_v4(); + let request = [9_u8; 32]; + complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + claimant, + true, + None, + operation_id, + request, + ) + .await + .expect("finish with receipt"); + complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + claimant, + true, + None, + operation_id, + request, + ) + .await + .expect("idempotent receipt retry"); + assert!(complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + claimant, + true, + None, + operation_id, + [10_u8; 32], + ) + .await + .is_err()); + assert!(!audio_admission_is_active( + &db, + community_id, + admission_id, + channel_id, + &member, + claimant, + ) + .await + .expect("terminal attempt cannot reappear")); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn orphaned_active_and_visible_admissions_both_abort() { + let (db, community_id, channel_id, member) = setup().await; + let active_id = Uuid::new_v4(); + let visible_id = Uuid::new_v4(); + let claimant = Uuid::new_v4(); + let lease_expires_at = epoch_after(300); + for admission_id in [active_id, visible_id] { + let mut reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut reserve, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("reserve attempt"); + reserve.commit().await.expect("commit reserve"); + let mut activate = db.begin_transaction().await.expect("activate tx"); + activate_audio_admission_tx( + &mut activate, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("activate attempt"); + activate.commit().await.expect("commit activation"); + } + mark_audio_admission_visible_with_receipt( + &db, + community_id, + visible_id, + claimant, + Uuid::new_v4(), + [21_u8; 32], + ) + .await + .expect("record observed visibility"); + for admission_id in [active_id, visible_id] { + request_audio_admission_cleanup_with_receipt( + &db, + community_id, + admission_id, + claimant, + Uuid::new_v4(), + [22_u8; 32], + ) + .await + .expect("request cleanup"); + } + sqlx::query( + "UPDATE audio_session_admissions \ + SET claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id IN ($2,$3)", + ) + .bind(community_id.as_uuid()) + .bind(active_id) + .bind(visible_id) + .execute(&db.pool) + .await + .expect("expire both durable claimants"); + + let candidates = reconcilable_audio_admissions(&db, community_id) + .await + .expect("discover both crash states"); + assert!(candidates.iter().any(|candidate| { + candidate.admission_id == active_id + && candidate.source_state == AudioAdmissionReconciliationState::Active + })); + assert!(candidates.iter().any(|candidate| { + candidate.admission_id == visible_id + && candidate.source_state == AudioAdmissionReconciliationState::Visible + })); + for candidate in candidates { + assert!(reconcile_claimed_audio_admission_with_receipt( + &db, + community_id, + candidate, + false, + Some("orphaned_attachment"), + Uuid::new_v4(), + [23_u8; 32], + ) + .await + .expect("reconcile exact state")); + } + let states: Vec<(Uuid, String)> = sqlx::query_as( + "SELECT admission_id, state FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id IN ($2,$3)", + ) + .bind(community_id.as_uuid()) + .bind(active_id) + .bind(visible_id) + .fetch_all(&db.pool) + .await + .expect("terminal states"); + assert!(states.contains(&(active_id, "aborted".to_owned()))); + assert!(states.contains(&(visible_id, "aborted".to_owned()))); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn expired_visible_admission_cannot_finish_and_can_be_compensated() { + let (db, community_id, channel_id, member) = setup().await; + let admission_id = Uuid::new_v4(); + let claimant = Uuid::new_v4(); + let lease_expires_at = epoch_after(300); + let mut reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut reserve, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("reserve attempt"); + reserve.commit().await.expect("commit reserve"); + let mut activate = db.begin_transaction().await.expect("activate tx"); + activate_audio_admission_tx( + &mut activate, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("activate attempt"); + activate.commit().await.expect("commit activation"); + mark_audio_admission_visible_with_receipt( + &db, + community_id, + admission_id, + claimant, + Uuid::new_v4(), + [0xa1; 32], + ) + .await + .expect("record visible attachment"); + sqlx::query( + "UPDATE audio_session_admissions \ + SET claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .expect("expire admission and durable owner claim"); + + let finish_operation = Uuid::new_v4(); + assert!(complete_claimed_audio_admission_with_receipt( + &db, + community_id, + admission_id, + claimant, + true, + None, + finish_operation, + [0xa2; 32], + ) + .await + .is_err()); + assert!(db + .authorization_operation_receipt_fingerprint(community_id, finish_operation) + .await + .expect("query rejected finish receipt") + .is_none()); + + let candidate = reconcilable_audio_admissions(&db, community_id) + .await + .expect("expired visible admission is reconcilable") + .into_iter() + .find(|candidate| candidate.admission_id == admission_id) + .expect("visible orphan candidate"); + assert_eq!( + candidate.source_state, + AudioAdmissionReconciliationState::Visible + ); + assert!(reconcile_claimed_audio_admission_with_receipt( + &db, + community_id, + candidate, + false, + Some("orphaned_attachment"), + Uuid::new_v4(), + [0xa3; 32], + ) + .await + .expect("compensate expired attachment")); + let state: String = sqlx::query_scalar( + "SELECT state FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_one(&db.pool) + .await + .expect("terminal state"); + assert_eq!(state, "aborted"); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn stale_reserved_reconciliation_cannot_abort_newly_active_attempt() { + let (db, community_id, channel_id, member) = setup().await; + let admission_id = Uuid::new_v4(); + let claimant = Uuid::new_v4(); + let lease_expires_at = epoch_after(300); + let mut reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut reserve, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("reserve attempt"); + reserve.commit().await.expect("commit reserve"); + sqlx::query( + "UPDATE audio_session_admissions \ + SET updated_at=clock_timestamp()-interval '3 minutes' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .expect("age reservation"); + assert!(reconcilable_audio_admissions(&db, community_id) + .await + .expect("age is not durable takeover evidence") + .is_empty()); + sqlx::query( + "UPDATE audio_session_admissions \ + SET claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .expect("expire claimant after healthy-owner assertion"); + let candidate = reconcilable_audio_admissions(&db, community_id) + .await + .expect("discover stale reservation") + .into_iter() + .find(|candidate| candidate.admission_id == admission_id) + .expect("candidate"); + assert_eq!( + candidate.source_state, + AudioAdmissionReconciliationState::Reserved + ); + + let mut activate = db.begin_transaction().await.expect("activation tx"); + activate_audio_admission_tx( + &mut activate, + community_id, + admission_id, + channel_id, + &member, + claimant, + lease_expires_at, + ) + .await + .expect("activate after discovery"); + activate.commit().await.expect("commit activation"); + + let changed = reconcile_claimed_audio_admission_with_receipt( + &db, + community_id, + candidate, + false, + Some("stale_reservation"), + Uuid::new_v4(), + [0x81; 32], + ) + .await + .expect("stale reconciliation loses CAS without error"); + assert!(!changed); + let state: (String, i64) = sqlx::query_as( + "SELECT state, state_version FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_one(&db.pool) + .await + .expect("durable admission"); + assert_eq!(state, ("active".to_owned(), candidate.state_version + 1)); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn expired_claim_has_one_idempotent_multi_replica_takeover() { + let (db, community_id, channel_id, member) = setup().await; + let admission_id = Uuid::new_v4(); + let claimant = Uuid::new_v4(); + let mut reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut reserve, + community_id, + admission_id, + channel_id, + &member, + claimant, + epoch_after(300), + ) + .await + .expect("reserve attempt"); + reserve.commit().await.expect("commit reserve"); + sqlx::query( + "UPDATE audio_session_admissions \ + SET claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .execute(&db.pool) + .await + .expect("expire durable claim"); + let candidate = reconcilable_audio_admissions(&db, community_id) + .await + .expect("discover orphan") + .into_iter() + .find(|candidate| candidate.admission_id == admission_id) + .expect("orphan candidate"); + let operation_id = Uuid::new_v4(); + let fingerprint = [0x91; 32]; + let db_b = db.clone(); + let (replica_a, replica_b) = tokio::join!( + reconcile_claimed_audio_admission_with_receipt( + &db, + community_id, + candidate, + false, + Some("orphaned_attachment"), + operation_id, + fingerprint, + ), + reconcile_claimed_audio_admission_with_receipt( + &db_b, + community_id, + candidate, + false, + Some("orphaned_attachment"), + operation_id, + fingerprint, + ), + ); + assert!(replica_a.expect("replica A converges")); + assert!(replica_b.expect("replica B converges")); + let lifecycle: (String, i64) = sqlx::query_as( + "SELECT state,state_version FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(admission_id) + .fetch_one(&db.pool) + .await + .expect("terminal lifecycle"); + assert_eq!(lifecycle, ("aborted".to_owned(), 2)); + let receipts: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_one(&db.pool) + .await + .expect("single takeover receipt"); + assert_eq!(receipts, 1); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn activation_rechecks_expiry_and_membership() { + let (db, community_id, channel_id, member) = setup().await; + + let expired_id = Uuid::new_v4(); + let future_expiry = epoch_after(60); + let mut expired_reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut expired_reserve, + community_id, + expired_id, + channel_id, + &member, + Uuid::from_u128(0x1002), + future_expiry, + ) + .await + .expect("reserve expiring attempt"); + expired_reserve.commit().await.expect("commit reserve"); + let expired_at = epoch_after(0).saturating_sub(1); + sqlx::query( + "UPDATE audio_session_admissions \ + SET admitted_at=to_timestamp($3::double precision)-interval '1 second', \ + lease_expires_at=to_timestamp($3::double precision) \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(expired_id) + .bind(expired_at as f64) + .execute(&db.pool) + .await + .expect("expire attempt deterministically"); + let mut expired_activation = db.begin_transaction().await.expect("activation tx"); + assert!(activate_audio_admission_tx( + &mut expired_activation, + community_id, + expired_id, + channel_id, + &member, + Uuid::from_u128(0x1002), + expired_at, + ) + .await + .is_err()); + expired_activation + .rollback() + .await + .expect("rollback expired activation"); + + let revoked_id = Uuid::new_v4(); + let revoked_expiry = epoch_after(60); + let mut revoked_reserve = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut revoked_reserve, + community_id, + revoked_id, + channel_id, + &member, + Uuid::from_u128(0x1003), + revoked_expiry, + ) + .await + .expect("reserve membership attempt"); + revoked_reserve.commit().await.expect("commit reserve"); + sqlx::query( + "UPDATE channel_members SET removed_at=clock_timestamp() \ + WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(member.as_slice()) + .execute(&db.pool) + .await + .expect("remove membership"); + let mut revoked_activation = db.begin_transaction().await.expect("activation tx"); + assert!(activate_audio_admission_tx( + &mut revoked_activation, + community_id, + revoked_id, + channel_id, + &member, + Uuid::from_u128(0x1003), + revoked_expiry, + ) + .await + .is_err()); + revoked_activation + .rollback() + .await + .expect("rollback revoked activation"); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn rollback_and_reconciliation_are_fail_closed() { + let (db, community_id, channel_id, member) = setup().await; + let rolled_back_id = Uuid::new_v4(); + let mut rolled_back = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut rolled_back, + community_id, + rolled_back_id, + channel_id, + &member, + Uuid::from_u128(0x1004), + epoch_after(60), + ) + .await + .expect("reserve before rollback"); + rolled_back.rollback().await.expect("rollback reserve"); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(rolled_back_id) + .fetch_one(&db.pool) + .await + .expect("rolled-back count"); + assert_eq!(count, 0); + + let stale_id = Uuid::new_v4(); + let active_id = Uuid::new_v4(); + let lease_expires_at = epoch_after(300); + for (admission_id, claimant_id) in [ + (stale_id, Uuid::from_u128(0x1005)), + (active_id, Uuid::from_u128(0x1006)), + ] { + let mut transaction = db.begin_transaction().await.expect("reserve tx"); + admit_existing_audio_member_tx( + &mut transaction, + community_id, + admission_id, + channel_id, + &member, + claimant_id, + lease_expires_at, + ) + .await + .expect("reserve attempt"); + transaction.commit().await.expect("commit reserve"); + } + sqlx::query( + "UPDATE audio_session_admissions \ + SET updated_at=clock_timestamp()-interval '3 minutes', \ + claim_expires_at=clock_timestamp()-interval '31 seconds' \ + WHERE community_id=$1 AND admission_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(stale_id) + .execute(&db.pool) + .await + .expect("age reserved attempt"); + let mut activation = db.begin_transaction().await.expect("activation tx"); + activate_audio_admission_tx( + &mut activation, + community_id, + active_id, + channel_id, + &member, + Uuid::from_u128(0x1006), + lease_expires_at, + ) + .await + .expect("activate live attempt"); + activation.commit().await.expect("commit activation"); + + let candidates = reconcilable_audio_admissions(&db, community_id) + .await + .expect("discover reconciliation candidates"); + assert!(candidates.iter().any(|candidate| { + candidate.admission_id == stale_id + && candidate.claimant_id == Uuid::from_u128(0x1005) + && candidate.source_state == AudioAdmissionReconciliationState::Reserved + })); + for candidate in candidates { + let operation_id = Uuid::new_v4(); + let finished = candidate.source_state.visibility_was_observed(); + complete_claimed_audio_admission_with_receipt( + &db, + community_id, + candidate.admission_id, + candidate.claimant_id, + finished, + candidate.source_state.abort_failure_code(), + operation_id, + [7_u8; 32], + ) + .await + .expect("reconcile exact candidate"); + } + let states: Vec<(Uuid, String)> = sqlx::query_as( + "SELECT admission_id, state FROM audio_session_admissions \ + WHERE community_id=$1 AND admission_id IN ($2, $3) \ + ORDER BY admission_id", + ) + .bind(community_id.as_uuid()) + .bind(stale_id) + .bind(active_id) + .fetch_all(&db.pool) + .await + .expect("lifecycle states"); + assert!(states.contains(&(stale_id, "aborted".to_owned()))); + assert!(states.contains(&(active_id, "active".to_owned()))); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn reconciliation_cursor_covers_more_than_one_bounded_page() { + let (db, community_id, channel_id, member) = setup().await; + sqlx::query( + "INSERT INTO audio_session_admissions \ + (community_id,admission_id,channel_id,pubkey,lease_expires_at,admitted_at, \ + state,state_version,updated_at,claimant_id,attachment_generation,claim_expires_at) \ + SELECT $1,gen_random_uuid(),$2,$3, \ + clock_timestamp()-interval '1 minute', \ + clock_timestamp()-interval '2 minutes', \ + 'reserved',1,clock_timestamp()-interval '2 minutes', \ + gen_random_uuid(),0,clock_timestamp()-interval '1 minute' \ + FROM generate_series(1,300)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(member.as_slice()) + .execute(&db.pool) + .await + .expect("populate more than one reconciliation page"); + + let first = reconcilable_audio_admissions_after(&db, community_id, None) + .await + .expect("first page"); + assert_eq!(first.len(), 256); + let cursor = first.last().expect("first page cursor").admission_id; + let second = reconcilable_audio_admissions_after(&db, community_id, Some(cursor)) + .await + .expect("second page"); + assert_eq!(second.len(), 44); + assert!(second + .iter() + .all(|candidate| candidate.admission_id > cursor)); + assert!(reconcilable_audio_admissions_after( + &db, + community_id, + second.last().map(|candidate| candidate.admission_id), + ) + .await + .expect("empty terminal page") + .is_empty()); + } +} diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 89e15c7026..bb4dd6174b 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; -use sqlx::{PgPool, Row}; +use sqlx::{PgPool, Postgres, Row, Transaction}; use uuid::Uuid; use crate::channel::ChannelRecord; @@ -387,6 +387,164 @@ pub async fn open_dm( Ok((channel, true)) } +/// Open or retrieve a DM inside a caller-owned authorization transaction. +/// The participant-set advisory lock serializes first creation, and every +/// channel/member change commits with the caller's operation receipt. +pub async fn open_dm_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkeys: &[&[u8]], + created_by: &[u8], +) -> Result<(ChannelRecord, bool)> { + let mut all: Vec<&[u8]> = pubkeys.to_vec(); + if !all.contains(&created_by) { + all.push(created_by); + } + all.sort_unstable(); + all.dedup(); + if !(2..=9).contains(&all.len()) || all.iter().any(|pubkey| pubkey.len() != 32) { + return Err(DbError::InvalidData( + "DM requires 2-9 valid participant pubkeys".to_string(), + )); + } + let hash = compute_participant_hash(&all); + let lock_key = i64::from_be_bytes(hash[..8].try_into().expect("eight-byte digest prefix")); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx) + .await?; + + let existing = sqlx::query( + r#" + SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, + description, canvas, created_by, created_at, updated_at, archived_at, + deleted_at, nip29_group_id, topic_required, max_members, topic, + topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at + FROM channels + WHERE community_id = $1 AND participant_hash = $2 + AND channel_type = 'dm' AND deleted_at IS NULL + LIMIT 1 FOR SHARE + "#, + ) + .bind(community_id.as_uuid()) + .bind(hash.as_slice()) + .fetch_optional(&mut **tx) + .await?; + if let Some(row) = existing { + let channel = row_to_channel_record(row)?; + sqlx::query( + "UPDATE channel_members SET hidden_at = NULL \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel.id) + .bind(created_by) + .execute(&mut **tx) + .await?; + return Ok((channel, false)); + } + + let id = Uuid::new_v4(); + let name = if all.len() == 2 { + "DM".to_string() + } else { + format!("Group DM ({})", all.len()) + }; + sqlx::query( + "INSERT INTO channels \ + (id, community_id, name, channel_type, visibility, created_by, participant_hash) \ + VALUES ($1, $2, $3, 'dm', 'private', $4, $5)", + ) + .bind(id) + .bind(community_id.as_uuid()) + .bind(name) + .bind(created_by) + .bind(hash.as_slice()) + .execute(&mut **tx) + .await?; + for pubkey in &all { + sqlx::query( + "INSERT INTO channel_members \ + (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $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(id) + .bind(*pubkey) + .bind(created_by) + .execute(&mut **tx) + .await?; + } + let row = sqlx::query( + r#" + SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, + description, canvas, created_by, created_at, updated_at, archived_at, + deleted_at, nip29_group_id, topic_required, max_members, topic, + topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at + FROM channels WHERE community_id = $1 AND id = $2 + "#, + ) + .bind(community_id.as_uuid()) + .bind(id) + .fetch_one(&mut **tx) + .await?; + Ok((row_to_channel_record(row)?, true)) +} + +/// Read and lock a source DM's active participant set, require the actor to be +/// an active member, then open the expanded immutable participant set inside +/// the same transaction. +pub async fn expand_dm_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + source_channel_id: Uuid, + additions: &[Vec], + actor: &[u8], +) -> Result<(ChannelRecord, bool, Vec>)> { + let channel_type = sqlx::query_scalar::<_, String>( + "SELECT channel_type::text FROM channels \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(source_channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| DbError::NotFound(format!("DM {source_channel_id}")))?; + if channel_type != "dm" { + return Err(DbError::AccessDenied("channel is not a DM".into())); + } + let mut participants = sqlx::query_scalar::<_, Vec>( + "SELECT pubkey FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND removed_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(source_channel_id) + .fetch_all(&mut **tx) + .await?; + if !participants.iter().any(|pubkey| pubkey == actor) { + return Err(DbError::AccessDenied("actor is not a DM member".into())); + } + for pubkey in additions { + if pubkey.len() != 32 { + return Err(DbError::InvalidData("invalid DM participant pubkey".into())); + } + if !participants.contains(pubkey) { + participants.push(pubkey.clone()); + } + } + if participants.len() > 9 { + return Err(DbError::InvalidData( + "DM supports at most 9 participants".into(), + )); + } + let refs = participants.iter().map(Vec::as_slice).collect::>(); + let (channel, created) = open_dm_tx(tx, community_id, &refs, actor).await?; + Ok((channel, created, participants)) +} + // -- Hide / unhide ------------------------------------------------------------ /// Hide a DM for a specific user by setting `hidden_at = NOW()`. @@ -422,6 +580,36 @@ pub async fn hide_dm( Ok(()) } +/// Hide a DM inside a caller-owned authorization transaction. The joined +/// channel predicate makes the active membership and DM type part of the +/// authoritative update rather than an adjacent preflight. +pub async fn hide_dm_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result<()> { + let result = sqlx::query( + "UPDATE channel_members AS cm SET hidden_at = NOW() \ + FROM channels AS c \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 \ + AND cm.removed_at IS NULL \ + AND c.community_id = cm.community_id AND c.id = cm.channel_id \ + AND c.channel_type = 'dm' AND c.deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::AccessDenied( + "actor is not an active DM member".into(), + )); + } + Ok(()) +} + /// Unhide a DM for a specific user by clearing `hidden_at`. /// /// This is called automatically when a user re-opens a DM via [`open_dm`]. diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a13402..e81b30abee 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -329,6 +329,34 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result, + q: &EventQuery, +) -> Result> { + query_events_on(transaction, q).await +} + +/// Share-lock a live event through the caller's commit boundary. Callers that +/// first resolve an authorization-bearing event must use this before trusting +/// its contents so replacement or deletion cannot race the protected effect. +pub async fn lock_live_event_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event_id: &[u8], +) -> Result { + Ok(sqlx::query( + "SELECT 1 FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut **transaction) + .await? + .is_some()) +} + /// [`query_events`] on a specific session — the replica-routing path runs /// follow-up (aux) queries on the exact reader connection whose heartbeat /// observation proved coverage for the page they annotate. @@ -980,6 +1008,30 @@ pub async fn get_event_by_id( } } +/// Fetch and share-lock one non-deleted event inside a caller-owned +/// transaction. The lock keeps deletion or replacement from changing the edit +/// target after ownership is validated and before the edit commits. +pub async fn get_event_by_id_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + id_bytes: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + ORDER BY created_at DESC LIMIT 1 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(id_bytes) + .fetch_optional(&mut **transaction) + .await?; + + match row { + Some(r) => row_to_stored_event(r), + None => Ok(None), + } +} + /// Fetches the latest global (non-channel, `channel_id IS NULL`) replaceable event /// for a (kind, pubkey) pair. /// @@ -1111,7 +1163,11 @@ pub struct ThreadMetadataParams<'a> { pub broadcast: bool, } -async fn insert_event_with_thread_metadata_tx( +/// Insert an event and optional thread metadata in a caller-owned transaction. +/// +/// Protected callers use this to commit the event, thread counters, and +/// authorization receipt at one PostgreSQL boundary. +pub async fn insert_event_with_thread_metadata_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, event: &Event, @@ -1280,6 +1336,585 @@ async fn insert_event_with_thread_metadata_tx( )) } +/// Replace one NIP-16 addressable event inside a caller-owned transaction. +pub async fn replace_addressable_event_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, + channel_id: Option, +) -> Result<(StoredEvent, bool)> { + let kind = event_kind_i32(event); + let pubkey = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let lock_key = crate::event_replacement_lock_key( + community_id, + kind, + pubkey.as_slice(), + channel_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx) + .await?; + let received_at = Utc::now(); + if sqlx::query_scalar::<_, i32>("SELECT 1 FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_optional(&mut **tx) + .await? + .is_some() + { + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + let existing: Option<(DateTime, Vec)> = sqlx::query_as( + "SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND channel_id IS NOT DISTINCT FROM $4 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await?; + if existing.as_ref().is_some_and(|(accepted_at, accepted_id)| { + created_at < *accepted_at + || (created_at == *accepted_at + && event.id.as_bytes().as_slice() >= accepted_id.as_slice()) + }) { + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND channel_id IS NOT DISTINCT FROM $4 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + let result = + insert_event_with_thread_metadata_tx(tx, community_id, event, channel_id, None).await?; + if !result.1 && existing.is_some() { + return Err(DbError::InvalidData( + "replacement insert conflicted after retiring the prior event".into(), + )); + } + Ok(result) +} + +/// Replace one NIP-33 parameterized event inside a caller-owned transaction. +pub async fn replace_parameterized_event_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, + d_tag: &str, + channel_id: Option, +) -> Result<(StoredEvent, bool)> { + let kind = event_kind_i32(event); + let pubkey = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let lock_key = crate::event_replacement_lock_key( + community_id, + kind, + pubkey.as_slice(), + Some(d_tag.as_bytes()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx) + .await?; + let received_at = Utc::now(); + if sqlx::query_scalar::<_, i32>("SELECT 1 FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_optional(&mut **tx) + .await? + .is_some() + { + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + let d_tag_count = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "d")) + .count(); + let has_exact_d_tag = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() >= 2 && parts[0] == "d" && parts[1] == d_tag + }); + let read_state_t_tag_count = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "t" && parts[1] == "read-state" + }) + .count(); + let is_nip_rs = kind == buzz_core::kind::KIND_READ_STATE as i32 + && d_tag_count == 1 + && has_exact_d_tag + && d_tag.strip_prefix("read-state:").is_some_and(|slot| { + slot.len() == 32 + && slot + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) + && read_state_t_tag_count == 1; + let is_buzz_mesh_status = kind == buzz_core::kind::KIND_BOOKMARK_SET as i32 + && d_tag.starts_with("buzz-mesh-member-status:") + && event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "k" && parts[1] == "buzz-mesh-status" + }); + let hard_delete_superseded = is_nip_rs || is_buzz_mesh_status; + let existing: Option<(DateTime, Vec)> = sqlx::query_as( + "SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(d_tag) + .fetch_optional(&mut **tx) + .await?; + let watermark: Option<(DateTime, Vec)> = if is_nip_rs { + sqlx::query_as( + "SELECT created_at, event_id FROM parameterized_event_watermarks \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(d_tag) + .fetch_optional(&mut **tx) + .await? + } else { + None + }; + let incoming_id = event.id.as_bytes().as_slice(); + if existing + .iter() + .chain(watermark.iter()) + .any(|(accepted_at, accepted_id)| { + created_at < *accepted_at + || (created_at == *accepted_at && incoming_id >= accepted_id.as_slice()) + }) + { + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + if existing.is_some() { + if is_nip_rs { + sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)") + .execute(&mut **tx) + .await?; + } + let statement = if hard_delete_superseded { + "DELETE FROM events WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" + } else { + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" + }; + sqlx::query(statement) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(d_tag) + .execute(&mut **tx) + .await?; + if hard_delete_superseded { + if let Some((_, existing_id)) = &existing { + sqlx::query("DELETE FROM event_mentions WHERE community_id = $1 AND event_id = $2") + .bind(community_id.as_uuid()) + .bind(existing_id) + .execute(&mut **tx) + .await?; + } + } + } + let sig = event.sig.serialize(); + let tags = serde_json::to_value(&event.tags)?; + let inserted = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, \ + received_at, channel_id, d_tag, not_before) VALUES \ + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(incoming_id) + .bind(pubkey.as_slice()) + .bind(created_at) + .bind(kind) + .bind(tags) + .bind(&event.content) + .bind(sig.as_slice()) + .bind(received_at) + .bind(channel_id) + .bind(d_tag) + .bind(extract_not_before(event)) + .execute(&mut **tx) + .await? + .rows_affected() + > 0; + if !inserted { + return Err(DbError::InvalidData( + "parameterized replacement insert conflicted after retiring the prior event".into(), + )); + } + if is_nip_rs { + sqlx::query( + "INSERT INTO parameterized_event_watermarks \ + (community_id, kind, pubkey, d_tag, created_at, event_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \ + created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(d_tag) + .bind(created_at) + .bind(incoming_id) + .execute(&mut **tx) + .await?; + } + Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), + true, + )) +} + +/// Apply the durable projection of a validated NIP-09 deletion inside the same +/// authorization transaction that stores the deletion event. +pub async fn apply_standard_deletion_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event: &Event, + actor: &[u8], + relay_pubkey: &[u8], +) -> Result<()> { + let targets = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == "e") + .then(|| hex::decode(&parts[1]).ok()) + .flatten() + .filter(|value| value.len() == 32) + }) + .collect::>(); + if targets.is_empty() { + if let Some(coordinate) = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == "a").then_some(parts[1].as_str()) + }) { + let parts = coordinate.splitn(3, ':').collect::>(); + if parts.len() == 3 { + let kind = parts[0].parse::().ok(); + let pubkey = hex::decode(parts[1]).ok(); + if let (Some(kind), Some(pubkey)) = (kind, pubkey) { + if pubkey.len() != 32 { + return Err(DbError::InvalidData( + "invalid addressable event pubkey".into(), + )); + } + if is_parameterized_replaceable(kind) + && kind != buzz_core::kind::KIND_WORKFLOW_DEF + && kind != buzz_core::kind::KIND_PUSH_LEASE + { + let owns_target = pubkey == actor + || sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE community_id = $1 \ + AND pubkey = $2 AND agent_owner_pubkey = $3)", + ) + .bind(community_id.as_uuid()) + .bind(&pubkey) + .bind(actor) + .fetch_one(&mut **tx) + .await?; + if !owns_target { + return Err(DbError::AccessDenied( + "actor does not own the addressable event".into(), + )); + } + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 \ + AND kind = $2 AND pubkey = $3 AND d_tag = $4 \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind as i32) + .bind(pubkey) + .bind(parts[2]) + .execute(&mut **tx) + .await?; + } + } + } + } + return Ok(()); + } + for target in targets { + let row = sqlx::query( + "SELECT e.kind, e.pubkey, e.tags, tm.parent_event_id, tm.root_event_id FROM events e \ + LEFT JOIN thread_metadata tm ON tm.community_id = e.community_id \ + AND tm.event_id = e.id AND tm.event_created_at = e.created_at \ + WHERE e.community_id = $1 AND e.id = $2 AND e.deleted_at IS NULL \ + ORDER BY e.created_at DESC LIMIT 1 FOR UPDATE OF e", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { continue }; + let kind: i32 = row.try_get("kind")?; + if kind == buzz_core::kind::KIND_PUSH_LEASE as i32 { + continue; + } + let stored_pubkey: Vec = row.try_get("pubkey")?; + let tags: serde_json::Value = row.try_get("tags")?; + let effective_author = if stored_pubkey == relay_pubkey { + tags.as_array() + .and_then(|tags| { + tags.iter().find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == "p") + .then(|| tag.get(1)?.as_str()) + .flatten() + }) + }) + .and_then(|value| hex::decode(value).ok()) + .filter(|value| value.len() == 32) + .unwrap_or(stored_pubkey) + } else { + stored_pubkey + }; + let owns_target = effective_author == actor + || sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE community_id = $1 \ + AND pubkey = $2 AND agent_owner_pubkey = $3)", + ) + .bind(community_id.as_uuid()) + .bind(&effective_author) + .bind(actor) + .fetch_one(&mut **tx) + .await?; + if !owns_target { + return Err(DbError::AccessDenied( + "actor does not own the target event".into(), + )); + } + let parent: Option> = row.try_get("parent_event_id")?; + let root: Option> = row.try_get("root_event_id")?; + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .execute(&mut **tx) + .await?; + if let Some(parent) = parent { + sqlx::query( + "UPDATE thread_metadata SET reply_count = GREATEST(reply_count - 1, 0) \ + WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(parent) + .execute(&mut **tx) + .await?; + } + if let Some(root) = root { + sqlx::query( + "UPDATE thread_metadata SET descendant_count = GREATEST(descendant_count - 1, 0) \ + WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(root) + .execute(&mut **tx) + .await?; + } + if kind == buzz_core::kind::KIND_REACTION as i32 { + sqlx::query( + "UPDATE reactions SET removed_at = NOW() WHERE community_id = $1 \ + AND reaction_event_id = $2 AND removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} + +/// Apply a NIP-29 channel-admin deletion inside the caller-owned sealed +/// authorization transaction. The target, its channel, and the actor's live +/// role/agent relationship are revalidated from locked rows before deletion. +pub async fn apply_nip29_delete_event_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + deletion: &Event, + actor: &[u8], + relay_pubkey: &[u8], + channel_id: Uuid, +) -> Result { + let target = deletion + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == "e") + .then(|| hex::decode(&parts[1]).ok()) + .flatten() + .filter(|value| value.len() == 32) + }) + .ok_or_else(|| DbError::InvalidData("missing deletion target".into()))?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "buzz_channel_membership:{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx) + .await?; + let row = sqlx::query( + "SELECT e.pubkey, e.tags, e.channel_id, tm.parent_event_id, tm.root_event_id \ + FROM events e LEFT JOIN thread_metadata tm \ + ON tm.community_id = e.community_id AND tm.event_id = e.id \ + AND tm.event_created_at = e.created_at \ + WHERE e.community_id = $1 AND e.id = $2 AND e.deleted_at IS NULL \ + ORDER BY e.created_at DESC LIMIT 1 FOR UPDATE OF e", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| DbError::NotFound("target event not found".into()))?; + let target_channel: Option = row.try_get("channel_id")?; + if target_channel != Some(channel_id) { + return Err(DbError::AccessDenied( + "target event belongs to a different channel".into(), + )); + } + let channel_visibility: String = sqlx::query_scalar( + "SELECT visibility::text FROM channels WHERE community_id = $1 AND id = $2 \ + AND deleted_at IS NULL FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let stored_pubkey: Vec = row.try_get("pubkey")?; + let tags: serde_json::Value = row.try_get("tags")?; + let effective_author = if stored_pubkey == relay_pubkey { + tags.as_array() + .and_then(|tags| { + tags.iter().find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == "p") + .then(|| tag.get(1)?.as_str()) + .flatten() + }) + }) + .and_then(|value| hex::decode(value).ok()) + .filter(|value| value.len() == 32) + .unwrap_or(stored_pubkey) + } else { + stored_pubkey + }; + let is_author = effective_author == actor; + let active_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM channel_members WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_one(&mut **tx) + .await?; + let author_path_allowed = is_author && (channel_visibility == "open" || active_member); + let elevated = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM channel_members WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL \ + AND role IN ('owner', 'admin'))", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_one(&mut **tx) + .await?; + let owns_author = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2 \ + AND agent_owner_pubkey = $3)", + ) + .bind(community_id.as_uuid()) + .bind(&effective_author) + .bind(actor) + .fetch_one(&mut **tx) + .await?; + if !author_path_allowed && !elevated && !owns_author { + return Err(DbError::AccessDenied( + "actor may not delete the target event".into(), + )); + } + let parent: Option> = row.try_get("parent_event_id")?; + let root: Option> = row.try_get("root_event_id")?; + let changed = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .execute(&mut **tx) + .await? + .rows_affected() + > 0; + if changed { + if let Some(parent) = parent { + sqlx::query( + "UPDATE thread_metadata SET reply_count = GREATEST(reply_count - 1, 0) \ + WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(parent) + .execute(&mut **tx) + .await?; + } + if let Some(root) = root { + sqlx::query( + "UPDATE thread_metadata SET descendant_count = GREATEST(descendant_count - 1, 0) \ + WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(root) + .execute(&mut **tx) + .await?; + } + } + Ok(changed) +} + /// Atomically insert an event and its optional thread metadata. /// /// `insert_event` and `insert_thread_metadata` calls could leave reply counters @@ -1320,6 +1955,33 @@ pub async fn insert_reaction_event_with_thread_metadata( ) -> Result { let mut tx = pool.begin().await?; + let result = insert_reaction_event_with_thread_metadata_tx( + &mut tx, + community_id, + reaction_event, + channel_id, + thread_meta, + target_event_id, + actor_pubkey, + emoji, + ) + .await?; + tx.commit().await?; + Ok(result) +} + +/// Insert a reaction and its event inside a caller-owned authorization transaction. +#[allow(clippy::too_many_arguments)] +pub async fn insert_reaction_event_with_thread_metadata_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + reaction_event: &Event, + channel_id: Option, + thread_meta: Option>, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, +) -> Result { let target_row = sqlx::query( "SELECT created_at FROM events \ WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ @@ -1327,18 +1989,17 @@ pub async fn insert_reaction_event_with_thread_metadata( ) .bind(community_id.as_uuid()) .bind(target_event_id) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; let Some(target_row) = target_row else { - tx.rollback().await?; return Ok(ReactionEventInsertOutcome::TargetMissing); }; let target_created_at: DateTime = target_row.get("created_at"); // Preserve add_reaction's exact new / re-activate / active-duplicate semantics. let reaction_inserted = crate::reaction::add_reaction_tx( - &mut tx, + tx, community_id, target_event_id, target_created_at, @@ -1349,12 +2010,11 @@ pub async fn insert_reaction_event_with_thread_metadata( .await?; if !reaction_inserted { - tx.rollback().await?; return Ok(ReactionEventInsertOutcome::Duplicate); } let (stored_event, was_inserted) = insert_event_with_thread_metadata_tx( - &mut tx, + tx, community_id, reaction_event, channel_id, @@ -1362,8 +2022,6 @@ pub async fn insert_reaction_event_with_thread_metadata( ) .await?; - tx.commit().await?; - Ok(ReactionEventInsertOutcome::Inserted { stored_event: Box::new(stored_event), was_inserted, @@ -1404,6 +2062,16 @@ pub async fn query_due_reminders( pool: &PgPool, now_secs: i64, batch_limit: i64, +) -> Result> { + query_due_reminders_excluding(pool, now_secs, batch_limit, &[]).await +} + +/// Query due reminders while leaving protected Enforce domains unclaimed. +pub async fn query_due_reminders_excluding( + pool: &PgPool, + now_secs: i64, + batch_limit: i64, + excluded_communities: &[Uuid], ) -> Result> { let kind_i32 = KIND_EVENT_REMINDER as i32; let rows = sqlx::query( @@ -1413,6 +2081,7 @@ pub async fn query_due_reminders( FROM events AS e JOIN communities AS c ON c.id = e.community_id WHERE e.kind = $1 + AND NOT (e.community_id = ANY($4::uuid[])) AND e.not_before IS NOT NULL AND e.not_before <= $2 AND e.deleted_at IS NULL @@ -1425,6 +2094,7 @@ pub async fn query_due_reminders( .bind(kind_i32) .bind(now_secs) .bind(batch_limit) + .bind(excluded_communities) .fetch_all(pool) .await?; @@ -2370,6 +3040,13 @@ mod tests { assert!(due.iter().any(|row| { row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b })); + + let excluded = + query_due_reminders_excluding(&pool, Utc::now().timestamp(), 100, &[community_a_uuid]) + .await + .expect("query with protected exclusion"); + assert!(!excluded.iter().any(|row| row.community_id == community_a)); + assert!(excluded.iter().any(|row| row.community_id == community_b)); } /// Two pods race to claim the same due reminder: exactly one wins. The diff --git a/crates/buzz-db/src/git_repo.rs b/crates/buzz-db/src/git_repo.rs index c1e47c0f8c..c5cb3d8c45 100644 --- a/crates/buzz-db/src/git_repo.rs +++ b/crates/buzz-db/src/git_repo.rs @@ -16,10 +16,11 @@ //! idempotent re-announce (same owner) from a collision (different owner), and //! backs the per-pubkey quota via `COUNT`. -use sqlx::{PgPool, Row as _}; +use nostr::Event; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, DbError, StoredEvent}; /// Outcome of a name-reservation attempt. /// @@ -155,6 +156,25 @@ pub async fn count_repos_for_owner( row.try_get("n").map_err(crate::error::DbError::from) } +/// Return the immutable publication origin for an existing reservation. +pub async fn repo_publication_origin( + pool: &PgPool, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, +) -> Result> { + sqlx::query_scalar( + "SELECT publication_origin FROM git_repo_names \ + WHERE community_id = $1 AND repo_id = $2 AND owner_pubkey = $3", + ) + .bind(community.as_uuid()) + .bind(repo_id) + .bind(owner_pubkey) + .fetch_optional(pool) + .await + .map_err(Into::into) +} + /// Release a reservation held by `owner_pubkey` (rollback path). /// /// Used only when seeding the manifest pointer fails *after* a fresh @@ -179,9 +199,127 @@ pub async fn release_repo_name( Ok(result.rows_affected()) } +/// Atomically replace a protected repository announcement and reserve its +/// tenant-local name inside the caller-owned authorization transaction. +/// +/// The owner-scoped advisory lock makes the quota exact across concurrent new +/// names, while the coordinate lock preserves NIP-33 timestamp/id ordering. +/// Re-announcing an existing same-owner name is idempotent and does not consume +/// quota. A different owner can never claim an already-reserved name. +pub async fn replace_protected_announcement_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + event: &Event, + repo_id: &str, + max_repos_per_owner: i64, +) -> Result<(StoredEvent, bool)> { + if max_repos_per_owner <= 0 { + return Err(DbError::InvalidData( + "repository quota must be positive".into(), + )); + } + let owner = hex::encode(event.pubkey.to_bytes()); + let coordinate_lock = format!( + "git-announcement:{}:{}:{}", + community.as_uuid(), + owner, + repo_id + ); + let name_lock = format!("git-name:{}:{}", community.as_uuid(), repo_id); + let quota_lock = format!("git-owner-quota:{}:{}", community.as_uuid(), owner); + for lock in [&name_lock, &coordinate_lock, "a_lock] { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(lock) + .execute(&mut **transaction) + .await?; + } + + let created_at_seconds = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_seconds, 0) + .ok_or(DbError::InvalidTimestamp(created_at_seconds))?; + let event_id = event.id.as_bytes().as_slice(); + let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( + "SELECT created_at, id FROM events \ + WHERE community_id = $1 AND kind = 30617 AND pubkey = $2 \ + AND d_tag = $3 AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(event.pubkey.to_bytes().as_slice()) + .bind(repo_id) + .fetch_optional(&mut **transaction) + .await?; + if existing.as_ref().is_some_and(|(accepted_at, accepted_id)| { + created_at < *accepted_at + || (created_at == *accepted_at && event_id >= accepted_id.as_slice()) + }) { + return Ok(( + StoredEvent::with_received_at(event.clone(), chrono::Utc::now(), None, false), + false, + )); + } + + let holder: Option = sqlx::query_scalar( + "SELECT owner_pubkey FROM git_repo_names \ + WHERE community_id = $1 AND repo_id = $2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(repo_id) + .fetch_optional(&mut **transaction) + .await?; + match holder.as_deref() { + Some(holder) if holder != owner => { + return Err(DbError::InvalidData( + "repository name is already reserved".into(), + )); + } + Some(_) => {} + None => { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM git_repo_names \ + WHERE community_id = $1 AND owner_pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(&owner) + .fetch_one(&mut **transaction) + .await?; + if count >= max_repos_per_owner { + return Err(DbError::InvalidData("repository quota exceeded".into())); + } + sqlx::query( + "INSERT INTO git_repo_names \ + (community_id, repo_id, owner_pubkey, publication_origin) \ + VALUES ($1, $2, $3, 'protected_unpublished')", + ) + .bind(community.as_uuid()) + .bind(repo_id) + .bind(&owner) + .execute(&mut **transaction) + .await?; + } + } + + if existing.is_some() { + sqlx::query( + "UPDATE events SET deleted_at = clock_timestamp() \ + WHERE community_id = $1 AND kind = 30617 AND pubkey = $2 \ + AND d_tag = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(event.pubkey.to_bytes().as_slice()) + .bind(repo_id) + .execute(&mut **transaction) + .await?; + } + + crate::event::insert_event_with_thread_metadata_tx(transaction, community, event, None, None) + .await +} + #[cfg(test)] mod tests { use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use uuid::Uuid; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; @@ -211,6 +349,110 @@ mod tests { format!("{:064x}", Uuid::new_v4().as_u128()) } + fn announcement(keys: &Keys, repo: &str, created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(30_617), "") + .tags([Tag::parse(["d", repo]).expect("d tag")]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("signed announcement") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_announcement_replaces_and_reserves_in_one_transaction() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let owner = Keys::generate(); + let repo = format!("repo-{}", Uuid::new_v4().simple()); + let first = announcement(&owner, &repo, 1_800_000_000); + let second = announcement(&owner, &repo, 1_800_000_001); + + let mut first_tx = pool.begin().await.expect("first transaction"); + let (_, inserted) = + replace_protected_announcement_tx(&mut first_tx, community, &first, &repo, 10) + .await + .expect("first announcement"); + assert!(inserted); + first_tx.commit().await.expect("commit first"); + + let mut second_tx = pool.begin().await.expect("second transaction"); + let (_, inserted) = + replace_protected_announcement_tx(&mut second_tx, community, &second, &repo, 10) + .await + .expect("replacement announcement"); + assert!(inserted); + second_tx.commit().await.expect("commit replacement"); + + assert_eq!( + repo_name_owner(&pool, community, &repo) + .await + .expect("registered owner"), + Some(owner.public_key().to_hex()) + ); + let live: Vec> = sqlx::query_scalar( + "SELECT id FROM events WHERE community_id = $1 AND kind = 30617 \ + AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(owner.public_key().to_bytes().as_slice()) + .bind(&repo) + .fetch_all(&pool) + .await + .expect("live announcements"); + assert_eq!(live, vec![second.id.as_bytes().to_vec()]); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_announcement_quota_is_exact_under_concurrency() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let owner = Keys::generate(); + let first_repo = format!("repo-a-{}", Uuid::new_v4().simple()); + let second_repo = format!("repo-b-{}", Uuid::new_v4().simple()); + let first = announcement(&owner, &first_repo, 1_800_000_000); + let second = announcement(&owner, &second_repo, 1_800_000_000); + + let first_attempt = async { + let mut transaction = pool.begin().await.expect("first transaction"); + let result = replace_protected_announcement_tx( + &mut transaction, + community, + &first, + &first_repo, + 1, + ) + .await; + if result.is_ok() { + transaction.commit().await.expect("first commit"); + } + result + }; + let second_attempt = async { + let mut transaction = pool.begin().await.expect("second transaction"); + let result = replace_protected_announcement_tx( + &mut transaction, + community, + &second, + &second_repo, + 1, + ) + .await; + if result.is_ok() { + transaction.commit().await.expect("second commit"); + } + result + }; + let (first_result, second_result) = tokio::join!(first_attempt, second_attempt); + assert_ne!(first_result.is_ok(), second_result.is_ok()); + assert_eq!( + count_repos_for_owner(&pool, community, &owner.public_key().to_hex()) + .await + .expect("quota count"), + 1 + ); + } + /// A fresh name is `Reserved`; re-announcing it as the *same* owner is /// `AlreadyOwned` (idempotent) and never grows the owner's count; a /// *different* owner is `TakenByOther`. diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs index be8b712d45..0426e5ecec 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/moderation.rs @@ -15,7 +15,7 @@ //! through the integration thread. use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use uuid::Uuid; use crate::error::Result; @@ -208,6 +208,49 @@ pub async fn insert_report( Ok(row.try_get("id")?) } +/// Insert a report inside a caller-owned transaction. +/// +/// Protected Enforce callers use this variant so the report row and the +/// authorization receipt share one commit boundary. +pub async fn insert_report_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + report: NewReport<'_>, +) -> Result { + let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target { + ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None), + ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None), + ReportTarget::Blob(sha256) => ("blob", None, None, Some(sha256.as_slice())), + }; + + let row = sqlx::query( + r#" + INSERT INTO moderation_reports ( + community_id, report_event_id, reporter_pubkey, target_kind, + target_event_id, target_pubkey, target_blob_sha256, channel_id, + report_type, note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (community_id, report_event_id) DO UPDATE SET + report_event_id = EXCLUDED.report_event_id + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(report.report_event_id) + .bind(report.reporter_pubkey) + .bind(target_kind) + .bind(target_event_id) + .bind(target_pubkey) + .bind(target_blob_sha256) + .bind(report.channel_id) + .bind(report.report_type) + .bind(report.note) + .fetch_one(&mut **transaction) + .await?; + + Ok(row.try_get("id")?) +} + /// List reports for the moderation queue, newest first. /// `status = None` lists all; `Some("open")` etc. filters. pub async fn list_reports( @@ -236,6 +279,32 @@ pub async fn list_reports( rows.into_iter().map(row_to_report).collect() } +/// List reports inside a caller-owned authorization transaction. +pub async fn list_reports_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + status: Option<&str>, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND ($2::text IS NULL OR status = $2) + ORDER BY created_at DESC + LIMIT $3 + "#, + ) + .bind(community.as_uuid()) + .bind(status) + .bind(limit) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter().map(row_to_report).collect() +} + /// Fetch one report by row id. pub async fn get_report( pool: &PgPool, @@ -282,6 +351,31 @@ pub async fn get_report_by_event( row.map(row_to_report).transpose() } +/// Lock and fetch a report by signed event id inside a caller-owned +/// transaction. +pub async fn get_report_by_event_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + report_event_id: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id, + target_pubkey, target_blob_sha256, channel_id, report_type, note, + status, resolved_by, resolved_at, action_id, created_at + FROM moderation_reports + WHERE community_id = $1 AND report_event_id = $2 + FOR UPDATE + "#, + ) + .bind(community.as_uuid()) + .bind(report_event_id) + .fetch_optional(&mut **transaction) + .await?; + + row.map(row_to_report).transpose() +} + /// Mark a report resolved/dismissed/escalated, linking the audit action. /// Returns `false` if the report was not found or already closed. pub async fn resolve_report( @@ -310,6 +404,33 @@ pub async fn resolve_report( Ok(result.rows_affected() > 0) } +/// Resolve a report inside a caller-owned transaction. +pub async fn resolve_report_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, +) -> Result { + let result = sqlx::query( + r#" + UPDATE moderation_reports + SET status = $3, resolved_by = $4, resolved_at = now(), action_id = $5 + WHERE community_id = $1 AND id = $2 AND status = 'open' + "#, + ) + .bind(community.as_uuid()) + .bind(report_id) + .bind(status) + .bind(resolved_by) + .bind(action_id) + .execute(&mut **transaction) + .await?; + + Ok(result.rows_affected() > 0) +} + /// Upsert a ban: sets `banned = true` with optional expiry + reason. pub async fn ban_member( pool: &PgPool, @@ -343,6 +464,38 @@ pub async fn ban_member( Ok(()) } +/// Upsert a ban inside a caller-owned transaction. +pub async fn ban_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, banned, ban_expires_at, ban_reason, actor_pubkey + ) VALUES ($1, $2, true, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + ban_expires_at = EXCLUDED.ban_expires_at, + ban_reason = EXCLUDED.ban_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expires_at) + .bind(reason) + .bind(actor) + .execute(&mut **transaction) + .await?; + Ok(()) +} + /// Lift a ban. Returns `false` if the member was not banned. pub async fn unban_member( pool: &PgPool, @@ -367,6 +520,29 @@ pub async fn unban_member( Ok(result.rows_affected() > 0) } +/// Lift a ban inside a caller-owned transaction. +pub async fn unban_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET banned = false, ban_expires_at = NULL, ban_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND banned = true + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Upsert a timeout: sets `muted_until` + reason. pub async fn timeout_member( pool: &PgPool, @@ -399,6 +575,37 @@ pub async fn timeout_member( Ok(()) } +/// Upsert a timeout inside a caller-owned transaction. +pub async fn timeout_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO community_bans ( + community_id, pubkey, muted_until, mute_reason, actor_pubkey + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + muted_until = EXCLUDED.muted_until, + mute_reason = EXCLUDED.mute_reason, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(muted_until) + .bind(reason) + .bind(actor) + .execute(&mut **transaction) + .await?; + Ok(()) +} + /// Clear a timeout early. Returns `false` if the member was not timed out. pub async fn untimeout_member( pool: &PgPool, @@ -423,6 +630,29 @@ pub async fn untimeout_member( Ok(result.rows_affected() > 0) } +/// Clear a timeout inside a caller-owned transaction. +pub async fn untimeout_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE community_bans + SET muted_until = NULL, mute_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND muted_until > now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Restriction snapshot consumed by the auth-seam gate (L4) and write gates. /// /// One cheap read per check: `banned` already accounts for expiry; @@ -466,6 +696,36 @@ pub async fn restriction_state( } } +/// Fetch and share-lock the current restriction state inside a caller-owned +/// authorization transaction. +pub async fn restriction_state_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: CommunityId, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + r#" + SELECT + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + CASE WHEN muted_until > now() THEN muted_until ELSE NULL END AS muted_until + FROM community_bans + WHERE community_id = $1 AND pubkey = $2 + FOR SHARE + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + match row { + Some(row) => Ok(RestrictionState { + banned: row.try_get("banned")?, + muted_until: row.try_get("muted_until")?, + }), + None => Ok(RestrictionState::default()), + } +} + /// Fetch the full ban/timeout row (moderation queue / audit views). pub async fn get_ban( pool: &PgPool, @@ -514,6 +774,32 @@ pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result, + community: CommunityId, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 + AND ( + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + OR muted_until > now() + ) + ORDER BY updated_at DESC + "#, + ) + .bind(community.as_uuid()) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter().map(row_to_ban).collect() +} + /// Insert a moderation audit row, returning its id. pub async fn insert_action( pool: &PgPool, @@ -545,6 +831,36 @@ pub async fn insert_action( Ok(row.try_get("id")?) } +/// Insert a moderation audit row inside a caller-owned transaction. +pub async fn insert_action_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + action: NewAction<'_>, +) -> Result { + let row = sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, target_event_id, + channel_id, reason_code, public_reason, private_reason, matched_principal + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(action.actor_pubkey) + .bind(action.action) + .bind(action.target_pubkey) + .bind(action.target_event_id) + .bind(action.channel_id) + .bind(action.reason_code) + .bind(action.public_reason) + .bind(action.private_reason) + .bind(action.matched_principal) + .fetch_one(&mut **transaction) + .await?; + Ok(row.try_get("id")?) +} + /// List audit rows, newest first (`buzz moderation audit`). pub async fn list_actions( pool: &PgPool, @@ -569,6 +885,29 @@ pub async fn list_actions( rows.into_iter().map(row_to_action).collect() } +/// List audit rows inside a caller-owned authorization transaction. +pub async fn list_actions_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + limit: i64, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, actor_pubkey, action, target_pubkey, target_event_id, channel_id, + reason_code, public_reason, private_reason, matched_principal, created_at + FROM moderation_actions + WHERE community_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(community.as_uuid()) + .bind(limit) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter().map(row_to_action).collect() +} + fn row_to_report(row: sqlx::postgres::PgRow) -> Result { let target_kind: String = row.try_get("target_kind")?; let target = match target_kind.as_str() { diff --git a/crates/buzz-db/src/product_feedback.rs b/crates/buzz-db/src/product_feedback.rs index 1a9f45e62b..1fd2782ba3 100644 --- a/crates/buzz-db/src/product_feedback.rs +++ b/crates/buzz-db/src/product_feedback.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use uuid::Uuid; use crate::{error::Result, CommunityId}; @@ -85,6 +85,38 @@ pub async fn insert( Ok(row.try_get("id")?) } +/// Insert product feedback inside a caller-owned authorization transaction. +/// The durable feedback row and the authorization receipt therefore commit or +/// roll back together. +pub async fn insert_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + feedback: NewProductFeedback<'_>, +) -> Result { + let row = sqlx::query( + r#" + INSERT INTO product_feedback ( + community_id, event_id, submitter_pubkey, category, body, tags, + event_created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (event_id) DO UPDATE SET + event_id = EXCLUDED.event_id + RETURNING id + "#, + ) + .bind(community.as_uuid()) + .bind(feedback.event_id) + .bind(feedback.submitter_pubkey) + .bind(feedback.category) + .bind(feedback.body) + .bind(feedback.tags) + .bind(feedback.event_created_at) + .fetch_one(&mut **transaction) + .await?; + + Ok(row.try_get("id")?) +} + /// List feedback across all communities, newest received first. pub async fn list(pool: &PgPool, limit: i64) -> Result> { let rows = sqlx::query( diff --git a/crates/buzz-db/src/protected_publication.rs b/crates/buzz-db/src/protected_publication.rs new file mode 100644 index 0000000000..766af27cd4 --- /dev/null +++ b/crates/buzz-db/src/protected_publication.rs @@ -0,0 +1,873 @@ +//! PostgreSQL-authoritative visibility for protected object-store content. + +use buzz_core::CommunityId; +use serde_json::Value; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::protected_visibility::{ + require_protected_object_authority, ProtectedObjectAuthorityState, ProtectedObjectSurface, +}; +use crate::{Db, DbError, Result}; + +/// Exact database policy state evaluated by the Git pre-receive hook. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GitPolicyCommitFence { + /// Exact active kind-30617 event evaluated by the hook. + pub announcement_id: String, + /// Channel bound by that announcement, when present. + pub channel_id: Option, + /// Exact database relationship used to derive the evaluated role. + pub grant: GitPolicyGrant, +} + +/// Database relationship that granted the evaluated push role. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GitPolicyGrant { + /// The authenticated pusher is the repository announcement author. + RepoOwner, + /// The authenticated pusher owns the managed-agent repository key. + ManagedAgentOwner, + /// The authenticated pusher held this exact active channel role. + ChannelMember { + /// Role text as stored in PostgreSQL and evaluated by the hook. + role: String, + }, +} + +/// Current Git publication selected by PostgreSQL. +#[derive(Clone, PartialEq, Eq)] +pub struct GitPublication { + /// Repository owner key encoded by the existing Git namespace. + pub owner_pubkey: String, + /// Verified immutable manifest digest. + pub manifest_sha256: String, + /// Monotonic compare-and-set version. + pub publication_version: u64, +} + +impl std::fmt::Debug for GitPublication { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GitPublication") + .field("owner_pubkey", &"[redacted]") + .field("manifest_sha256", &"[redacted]") + .field("publication_version", &self.publication_version) + .finish() + } +} + +/// Expected parent for one PostgreSQL Git publication CAS. +#[derive(Clone, PartialEq, Eq)] +pub struct ExpectedGitPublication { + /// Monotonic parent version. + pub publication_version: u64, + /// Exact parent manifest digest. + pub manifest_sha256: String, +} + +/// Outcome of a PostgreSQL Git publication CAS. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GitPublicationOutcome { + /// The new manifest became authoritative at this version. + Published(GitPublication), + /// The authoritative parent did not match the supplied expectation. + Conflict, +} + +/// Inputs for one PostgreSQL-authoritative Git publication. +pub struct GitPublicationRequest<'a> { + /// Community whose repository namespace is being changed. + pub community_id: CommunityId, + /// Stable repository identifier. + pub repo_id: &'a str, + /// Repository owner key encoded by the existing namespace. + pub owner_pubkey: &'a str, + /// Required parent publication, or no parent for initial publication. + pub expected: Option<&'a ExpectedGitPublication>, + /// Digest of the immutable manifest being published. + pub manifest_sha256: &'a str, + /// Authenticated pusher key evaluated by the policy fence. + pub pusher_pubkey: &'a [u8], + /// Exact policy state evaluated before the transaction began. + pub policy: &'a GitPolicyCommitFence, +} + +/// Canonical media publication metadata selected by PostgreSQL. +#[derive(Clone, PartialEq, Eq)] +pub struct MediaPublication { + /// Content digest. + pub sha256: String, + /// Immutable object-store key. + pub object_key: String, + /// Canonical path extension. + pub extension: String, + /// Canonical MIME type. + pub mime_type: String, + /// Immutable object size. + pub object_size: u64, + /// Bounded provider-neutral metadata. + pub metadata: Value, + /// Optional immutable thumbnail key. + pub thumbnail_key: Option, + /// Monotonic publication version. + pub publication_version: u64, +} + +impl std::fmt::Debug for MediaPublication { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MediaPublication") + .field("sha256", &"[redacted]") + .field("object_key", &"[redacted]") + .field("extension", &self.extension) + .field("mime_type", &self.mime_type) + .field("object_size", &self.object_size) + .field("metadata", &"[redacted]") + .field("thumbnail_key", &"[redacted]") + .field("publication_version", &self.publication_version) + .finish() + } +} + +fn positive_version(value: i64) -> Result { + u64::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| DbError::InvalidData("publication version is invalid".into())) +} + +fn nonnegative_size(value: i64) -> Result { + u64::try_from(value) + .map_err(|_| DbError::InvalidData("publication object size is invalid".into())) +} + +fn validate_digest(value: &str) -> Result<()> { + if value.len() != 64 + || !value + .chars() + .all(|character| matches!(character, '0'..='9' | 'a'..='f')) + { + return Err(DbError::InvalidData("publication digest is invalid".into())); + } + Ok(()) +} + +impl Db { + /// Read the active PostgreSQL-authoritative Git publication. + pub async fn git_publication( + &self, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT owner_pubkey, manifest_sha256, publication_version \ + FROM git_repo_publications \ + WHERE community_id = $1 AND repo_id = $2 AND owner_pubkey = $3 \ + AND state = 'active'", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .bind(owner_pubkey) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(GitPublication { + owner_pubkey: row.try_get("owner_pubkey")?, + manifest_sha256: row.try_get("manifest_sha256")?, + publication_version: positive_version(row.try_get("publication_version")?)?, + }) + }) + .transpose() + } + + /// Read the active PostgreSQL-authoritative media publication. + pub async fn media_publication( + &self, + community_id: CommunityId, + sha256: &str, + ) -> Result> { + validate_digest(sha256)?; + let row = sqlx::query( + "SELECT sha256, object_key, extension, mime_type, object_size, metadata, \ + thumbnail_key, publication_version \ + FROM media_publications \ + WHERE community_id = $1 AND sha256 = $2 AND state = 'active'", + ) + .bind(community_id.as_uuid()) + .bind(sha256) + .fetch_optional(&self.pool) + .await?; + row.map(media_publication_from_row).transpose() + } +} + +/// Compare and publish one Git manifest inside the caller-owned transaction. +pub async fn compare_and_publish_git( + transaction: &mut Transaction<'_, Postgres>, + request: GitPublicationRequest<'_>, +) -> Result { + let GitPublicationRequest { + community_id, + repo_id, + owner_pubkey, + expected, + manifest_sha256, + pusher_pubkey, + policy, + } = request; + validate_digest(manifest_sha256)?; + require_protected_object_authority( + transaction, + community_id, + ProtectedObjectSurface::Git, + ProtectedObjectAuthorityState::PostgreSql, + ) + .await?; + if repo_id.is_empty() || owner_pubkey.is_empty() { + return Err(DbError::InvalidData( + "Git publication identity is invalid".into(), + )); + } + validate_git_policy_fence( + transaction, + community_id, + repo_id, + owner_pubkey, + pusher_pubkey, + policy, + ) + .await?; + let registered_owner: Option = sqlx::query_scalar( + "SELECT owner_pubkey FROM git_repo_names \ + WHERE community_id = $1 AND repo_id = $2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .fetch_optional(&mut **transaction) + .await?; + if registered_owner.as_deref() != Some(owner_pubkey) { + return Err(DbError::InvalidData( + "Git publication does not match the registered owner".into(), + )); + } + let current = sqlx::query( + "SELECT owner_pubkey, manifest_sha256, publication_version, state \ + FROM git_repo_publications \ + WHERE community_id = $1 AND repo_id = $2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .fetch_optional(&mut **transaction) + .await?; + + match (current, expected) { + (None, None) => { + sqlx::query( + "INSERT INTO git_repo_publications \ + (community_id, repo_id, owner_pubkey, manifest_sha256, publication_version) \ + VALUES ($1, $2, $3, $4, 1)", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .bind(owner_pubkey) + .bind(manifest_sha256) + .execute(&mut **transaction) + .await?; + Ok(GitPublicationOutcome::Published(GitPublication { + owner_pubkey: owner_pubkey.to_owned(), + manifest_sha256: manifest_sha256.to_owned(), + publication_version: 1, + })) + } + (Some(row), Some(expected)) => { + let current_owner: String = row.try_get("owner_pubkey")?; + let current_digest: String = row.try_get("manifest_sha256")?; + let current_version = positive_version(row.try_get("publication_version")?)?; + let state: String = row.try_get("state")?; + if state != "active" + || current_owner != owner_pubkey + || current_version != expected.publication_version + || current_digest != expected.manifest_sha256 + { + return Ok(GitPublicationOutcome::Conflict); + } + let next = current_version + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("Git publication version exhausted".into()))?; + let next_i64 = i64::try_from(next) + .map_err(|_| DbError::InvalidData("Git publication version exhausted".into()))?; + sqlx::query( + "UPDATE git_repo_publications \ + SET manifest_sha256 = $3, publication_version = $4, \ + updated_at = clock_timestamp() \ + WHERE community_id = $1 AND repo_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .bind(manifest_sha256) + .bind(next_i64) + .execute(&mut **transaction) + .await?; + Ok(GitPublicationOutcome::Published(GitPublication { + owner_pubkey: owner_pubkey.to_owned(), + manifest_sha256: manifest_sha256.to_owned(), + publication_version: next, + })) + } + _ => Ok(GitPublicationOutcome::Conflict), + } +} + +async fn validate_git_policy_fence( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + pusher_pubkey: &[u8], + policy: &GitPolicyCommitFence, +) -> Result<()> { + let owner = hex::decode(owner_pubkey) + .map_err(|_| DbError::InvalidData("Git policy owner is invalid".into()))?; + let announcement = hex::decode(&policy.announcement_id) + .map_err(|_| DbError::InvalidData("Git policy announcement is invalid".into()))?; + if owner.len() != 32 || pusher_pubkey.len() != 32 || announcement.len() != 32 { + return Err(DbError::InvalidData( + "Git policy identity is invalid".into(), + )); + } + + let current: Option = sqlx::query_scalar( + "SELECT 1 FROM events \ + WHERE community_id = $1 AND id = $2 AND kind = 30617 \ + AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&announcement) + .bind(&owner) + .bind(repo_id) + .fetch_optional(&mut **transaction) + .await?; + if current.is_none() { + return Err(DbError::InvalidData( + "Git policy announcement changed before publication".into(), + )); + } + + if let Some(channel_id) = policy.channel_id { + let channel_active: Option = sqlx::query_scalar( + "SELECT 1 FROM channels \ + WHERE community_id = $1 AND id = $2 \ + AND archived_at IS NULL AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **transaction) + .await?; + if channel_active.is_none() { + return Err(DbError::InvalidData( + "Git policy channel changed before publication".into(), + )); + } + } + + let granted = match &policy.grant { + GitPolicyGrant::RepoOwner => pusher_pubkey == owner.as_slice(), + GitPolicyGrant::ManagedAgentOwner => { + let row: Option = sqlx::query_scalar( + "SELECT 1 FROM users \ + WHERE community_id = $1 AND pubkey = $2 \ + AND agent_owner_pubkey = $3 AND deactivated_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&owner) + .bind(pusher_pubkey) + .fetch_optional(&mut **transaction) + .await?; + row.is_some() + } + GitPolicyGrant::ChannelMember { role } => { + let Some(channel_id) = policy.channel_id else { + return Err(DbError::InvalidData("Git policy channel is missing".into())); + }; + let current_role: Option = sqlx::query_scalar( + "SELECT role::text FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pusher_pubkey) + .fetch_optional(&mut **transaction) + .await?; + current_role.as_deref() == Some(role.as_str()) + } + }; + if !granted { + return Err(DbError::InvalidData( + "Git policy grant changed before publication".into(), + )); + } + Ok(()) +} + +/// Publish immutable media metadata inside the caller-owned transaction. +/// +/// Republication of identical bytes/metadata is idempotent. A conflicting row +/// for the same content digest fails closed rather than silently changing what +/// an existing URL means. +pub async fn publish_media( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + publication: &MediaPublication, +) -> Result { + validate_digest(&publication.sha256)?; + require_protected_object_authority( + transaction, + community_id, + ProtectedObjectSurface::Media, + ProtectedObjectAuthorityState::PostgreSql, + ) + .await?; + let size = i64::try_from(publication.object_size) + .map_err(|_| DbError::InvalidData("media publication size is invalid".into()))?; + sqlx::query( + "INSERT INTO media_publications \ + (community_id, sha256, object_key, extension, mime_type, object_size, \ + metadata, thumbnail_key, publication_version, state) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 'active') \ + ON CONFLICT (community_id, sha256) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(&publication.sha256) + .bind(&publication.object_key) + .bind(&publication.extension) + .bind(&publication.mime_type) + .bind(size) + .bind(&publication.metadata) + .bind(&publication.thumbnail_key) + .execute(&mut **transaction) + .await?; + + let row = sqlx::query( + "SELECT sha256, object_key, extension, mime_type, object_size, metadata, \ + thumbnail_key, publication_version \ + FROM media_publications \ + WHERE community_id = $1 AND sha256 = $2 AND state = 'active' FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&publication.sha256) + .fetch_optional(&mut **transaction) + .await? + .ok_or_else(|| DbError::InvalidData("media publication is unavailable".into()))?; + let stored = media_publication_from_row(row)?; + if stored.object_key != publication.object_key + || stored.extension != publication.extension + || stored.mime_type != publication.mime_type + || stored.object_size != publication.object_size + || stored.metadata != publication.metadata + || stored.thumbnail_key != publication.thumbnail_key + { + return Err(DbError::InvalidData( + "media publication conflicts with existing content metadata".into(), + )); + } + Ok(stored) +} + +/// Idempotently import one legacy Git publication while visibility remains fenced. +pub async fn import_git_publication( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + manifest_sha256: &str, +) -> Result<()> { + validate_digest(manifest_sha256)?; + require_protected_object_authority( + transaction, + community_id, + ProtectedObjectSurface::Git, + ProtectedObjectAuthorityState::Importing, + ) + .await?; + sqlx::query( + "INSERT INTO git_repo_publications \ + (community_id, repo_id, owner_pubkey, manifest_sha256, publication_version, state) \ + VALUES ($1, $2, $3, $4, 1, 'active') \ + ON CONFLICT (community_id, repo_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .bind(owner_pubkey) + .bind(manifest_sha256) + .execute(&mut **transaction) + .await?; + let stored = sqlx::query( + "SELECT owner_pubkey, manifest_sha256, publication_version, state \ + FROM git_repo_publications WHERE community_id = $1 AND repo_id = $2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(repo_id) + .fetch_one(&mut **transaction) + .await?; + let stored_owner: String = stored.try_get("owner_pubkey")?; + let stored_digest: String = stored.try_get("manifest_sha256")?; + let stored_version = positive_version(stored.try_get("publication_version")?)?; + let stored_state: String = stored.try_get("state")?; + if stored_owner != owner_pubkey + || stored_digest != manifest_sha256 + || stored_version != 1 + || stored_state != "active" + { + return Err(DbError::InvalidData( + "Git import conflicts with an existing publication".into(), + )); + } + Ok(()) +} + +/// Idempotently import one legacy media publication while visibility remains fenced. +pub async fn import_media_publication( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + publication: &MediaPublication, +) -> Result<()> { + require_protected_object_authority( + transaction, + community_id, + ProtectedObjectSurface::Media, + ProtectedObjectAuthorityState::Importing, + ) + .await?; + // `publish_media` requires the final PostgreSQL state, so reproduce its + // exact idempotent row contract under the import-state lock. + validate_digest(&publication.sha256)?; + let size = i64::try_from(publication.object_size) + .map_err(|_| DbError::InvalidData("media publication size is invalid".into()))?; + sqlx::query( + "INSERT INTO media_publications \ + (community_id, sha256, object_key, extension, mime_type, object_size, \ + metadata, thumbnail_key, publication_version, state) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 'active') \ + ON CONFLICT (community_id, sha256) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(&publication.sha256) + .bind(&publication.object_key) + .bind(&publication.extension) + .bind(&publication.mime_type) + .bind(size) + .bind(&publication.metadata) + .bind(&publication.thumbnail_key) + .execute(&mut **transaction) + .await?; + let row = sqlx::query( + "SELECT sha256, object_key, extension, mime_type, object_size, metadata, \ + thumbnail_key, publication_version \ + FROM media_publications \ + WHERE community_id = $1 AND sha256 = $2 AND state = 'active' FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&publication.sha256) + .fetch_one(&mut **transaction) + .await?; + let stored = media_publication_from_row(row)?; + if stored.object_key != publication.object_key + || stored.extension != publication.extension + || stored.mime_type != publication.mime_type + || stored.object_size != publication.object_size + || stored.metadata != publication.metadata + || stored.thumbnail_key != publication.thumbnail_key + || stored.publication_version != 1 + { + return Err(DbError::InvalidData( + "media import conflicts with an existing publication".into(), + )); + } + Ok(()) +} + +/// List exact Git repository reservations for one migration domain. +pub async fn list_git_repo_reservations( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, +) -> Result> { + require_protected_object_authority( + transaction, + community_id, + ProtectedObjectSurface::Git, + ProtectedObjectAuthorityState::Importing, + ) + .await?; + let rows = sqlx::query( + "SELECT repo_id, owner_pubkey, publication_origin FROM git_repo_names \ + WHERE community_id = $1 ORDER BY repo_id FOR SHARE", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter() + .map(|row| { + Ok(( + row.try_get("repo_id")?, + row.try_get("owner_pubkey")?, + row.try_get("publication_origin")?, + )) + }) + .collect() +} + +/// List the exact imported Git inventory for parity validation. +pub async fn list_git_publications( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, +) -> Result> { + let rows = sqlx::query( + "SELECT repo_id, owner_pubkey, manifest_sha256 FROM git_repo_publications \ + WHERE community_id = $1 AND state = 'active' ORDER BY repo_id FOR SHARE", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter() + .map(|row| { + Ok(( + row.try_get("repo_id")?, + row.try_get("owner_pubkey")?, + row.try_get("manifest_sha256")?, + )) + }) + .collect() +} + +/// List the exact imported media inventory for parity validation. +pub async fn list_media_publications( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, +) -> Result> { + let rows = sqlx::query( + "SELECT sha256, object_key, extension, mime_type, object_size, metadata, \ + thumbnail_key, publication_version FROM media_publications \ + WHERE community_id = $1 AND state = 'active' ORDER BY sha256 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **transaction) + .await?; + rows.into_iter().map(media_publication_from_row).collect() +} + +fn media_publication_from_row(row: sqlx::postgres::PgRow) -> Result { + Ok(MediaPublication { + sha256: row.try_get("sha256")?, + object_key: row.try_get("object_key")?, + extension: row.try_get("extension")?, + mime_type: row.try_get("mime_type")?, + object_size: nonnegative_size(row.try_get("object_size")?)?, + metadata: row.try_get("metadata")?, + thumbnail_key: row.try_get("thumbnail_key")?, + publication_version: positive_version(row.try_get("publication_version")?)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup() -> (Db, CommunityId) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url).await.expect("test database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("migrated test database"); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("publication-{}.example", id.simple())) + .execute(&pool) + .await + .expect("community"); + for surface in ["git", "media"] { + sqlx::query( + "INSERT INTO protected_object_authority \ + (community_id, surface, state, generation, imported_objects, \ + inventory_sha256, started_at, completed_at) \ + VALUES ($1, $2, 'postgresql', 2, 0, $3, \ + clock_timestamp(), clock_timestamp())", + ) + .bind(id) + .bind(surface) + .bind("0".repeat(64)) + .execute(&pool) + .await + .expect("protected object authority"); + } + (Db::from_pool(pool), CommunityId::from_uuid(id)) + } + + fn digest(byte: u8) -> String { + format!("{byte:02x}").repeat(32) + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn git_publication_is_owner_bound_and_compare_and_set() { + let (db, community) = setup().await; + let repo = format!("repo-{}", Uuid::new_v4().simple()); + let owner = digest(1); + let owner_bytes = hex::decode(&owner).expect("owner bytes"); + let announcement_id = digest(8); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, d_tag) \ + VALUES ($1, $2, $3, clock_timestamp(), 30617, $4, '', $5, $6)", + ) + .bind(community.as_uuid()) + .bind(hex::decode(&announcement_id).expect("announcement bytes")) + .bind(&owner_bytes) + .bind(serde_json::json!([["d", repo]])) + .bind(vec![0_u8; 64]) + .bind(&repo) + .execute(&db.pool) + .await + .expect("announcement"); + sqlx::query( + "INSERT INTO git_repo_names (community_id, repo_id, owner_pubkey) \ + VALUES ($1, $2, $3)", + ) + .bind(community.as_uuid()) + .bind(&repo) + .bind(&owner) + .execute(&db.pool) + .await + .expect("repo reservation"); + let policy = GitPolicyCommitFence { + announcement_id, + channel_id: None, + grant: GitPolicyGrant::RepoOwner, + }; + + let mut transaction = db.begin_transaction().await.expect("transaction"); + let first = compare_and_publish_git( + &mut transaction, + GitPublicationRequest { + community_id: community, + repo_id: &repo, + owner_pubkey: &owner, + expected: None, + manifest_sha256: &digest(2), + pusher_pubkey: &owner_bytes, + policy: &policy, + }, + ) + .await + .expect("first publication"); + transaction.commit().await.expect("commit"); + let GitPublicationOutcome::Published(first) = first else { + panic!("first publication must win") + }; + assert_eq!(first.publication_version, 1); + assert!(db + .git_publication(community, &repo, &digest(9)) + .await + .expect("wrong-owner read") + .is_none()); + + let mut stale = db.begin_transaction().await.expect("stale transaction"); + assert_eq!( + compare_and_publish_git( + &mut stale, + GitPublicationRequest { + community_id: community, + repo_id: &repo, + owner_pubkey: &owner, + expected: None, + manifest_sha256: &digest(3), + pusher_pubkey: &owner_bytes, + policy: &policy, + }, + ) + .await + .expect("stale compare"), + GitPublicationOutcome::Conflict + ); + stale.rollback().await.expect("rollback stale compare"); + + let mut next = db.begin_transaction().await.expect("next transaction"); + let expected = ExpectedGitPublication { + publication_version: first.publication_version, + manifest_sha256: first.manifest_sha256, + }; + let second = compare_and_publish_git( + &mut next, + GitPublicationRequest { + community_id: community, + repo_id: &repo, + owner_pubkey: &owner, + expected: Some(&expected), + manifest_sha256: &digest(3), + pusher_pubkey: &owner_bytes, + policy: &policy, + }, + ) + .await + .expect("next publication"); + next.commit().await.expect("commit next"); + assert!(matches!( + second, + GitPublicationOutcome::Published(GitPublication { + publication_version: 2, + .. + }) + )); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn media_visibility_is_transaction_owned_and_rollback_leaves_no_row() { + let (db, community) = setup().await; + let publication = MediaPublication { + sha256: digest(4), + object_key: format!("{}.jpg", digest(4)), + extension: "jpg".into(), + mime_type: "image/jpeg".into(), + object_size: 17, + metadata: serde_json::json!({"synthetic": true}), + thumbnail_key: None, + publication_version: 1, + }; + let mut rolled_back = db.begin_transaction().await.expect("transaction"); + publish_media(&mut rolled_back, community, &publication) + .await + .expect("staged publication"); + rolled_back.rollback().await.expect("rollback"); + assert!(db + .media_publication(community, &publication.sha256) + .await + .expect("publication read") + .is_none()); + + let mut committed = db.begin_transaction().await.expect("transaction"); + publish_media(&mut committed, community, &publication) + .await + .expect("publication"); + committed.commit().await.expect("commit"); + assert_eq!( + db.media_publication(community, &publication.sha256) + .await + .expect("publication read") + .expect("published row") + .object_key, + publication.object_key + ); + } +} diff --git a/crates/buzz-db/src/protected_visibility.rs b/crates/buzz-db/src/protected_visibility.rs new file mode 100644 index 0000000000..f820071282 --- /dev/null +++ b/crates/buzz-db/src/protected_visibility.rs @@ -0,0 +1,396 @@ +//! Monotonic migration authority for protected Git and media visibility. + +use buzz_core::CommunityId; +use sqlx::{Postgres, Row, Transaction}; + +use crate::{Db, DbError, Result}; + +/// Object-store visibility family being migrated. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedObjectSurface { + /// Git repository pointers and manifests. + Git, + /// Media sidecars and immutable blobs. + Media, +} + +impl ProtectedObjectSurface { + fn as_str(self) -> &'static str { + match self { + Self::Git => "git", + Self::Media => "media", + } + } +} + +/// Durable authority state for one community and surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedObjectAuthorityState { + /// Legacy pointer or sidecar remains authoritative. + Legacy, + /// Legacy writes are fenced while an idempotent import is validated. + Importing, + /// PostgreSQL is the sole visibility authority. + PostgreSql, +} + +/// Current durable migration state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtectedObjectAuthority { + /// Monotonic migration generation. + pub generation: u64, + /// Current authority state. + pub state: ProtectedObjectAuthorityState, + /// Exact imported-object count recorded at cutover. + pub imported_objects: Option, + /// Exact imported inventory digest recorded at cutover. + pub inventory_sha256: Option, +} + +fn decode_state(state: &str) -> Result { + match state { + "legacy" => Ok(ProtectedObjectAuthorityState::Legacy), + "importing" => Ok(ProtectedObjectAuthorityState::Importing), + "postgresql" => Ok(ProtectedObjectAuthorityState::PostgreSql), + _ => Err(DbError::InvalidData( + "protected object authority state is invalid".into(), + )), + } +} + +fn decode_generation(value: i64) -> Result { + u64::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| { + DbError::InvalidData("protected object authority generation is invalid".into()) + }) +} + +async fn ensure_row( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + surface: ProtectedObjectSurface, +) -> Result<()> { + sqlx::query( + "INSERT INTO protected_object_authority \ + (community_id, surface, state, generation) VALUES ($1, $2, 'legacy', 1) \ + ON CONFLICT (community_id, surface) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +impl Db { + /// Read durable authority, treating an untouched domain as legacy. + pub async fn protected_object_authority( + &self, + community: CommunityId, + surface: ProtectedObjectSurface, + ) -> Result { + let row = sqlx::query( + "SELECT state, generation, imported_objects, inventory_sha256 \ + FROM protected_object_authority \ + WHERE community_id = $1 AND surface = $2", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .fetch_optional(&self.pool) + .await?; + match row { + Some(row) => { + let imported_objects: i64 = row.try_get("imported_objects")?; + Ok(ProtectedObjectAuthority { + state: decode_state(row.try_get("state")?)?, + generation: decode_generation(row.try_get("generation")?)?, + imported_objects: (imported_objects > 0) + .then(|| u64::try_from(imported_objects).ok()) + .flatten() + .or_else(|| (imported_objects == 0).then_some(0)), + inventory_sha256: row.try_get("inventory_sha256")?, + }) + } + None => Ok(ProtectedObjectAuthority { + state: ProtectedObjectAuthorityState::Legacy, + generation: 1, + imported_objects: None, + inventory_sha256: None, + }), + } + } + + /// Begin or resume an import after draining transaction-held legacy writers. + pub async fn begin_protected_object_import( + &self, + community: CommunityId, + surface: ProtectedObjectSurface, + ) -> Result { + let mut transaction = self.begin_transaction().await?; + ensure_row(&mut transaction, community, surface).await?; + let row = sqlx::query( + "SELECT state, generation FROM protected_object_authority \ + WHERE community_id = $1 AND surface = $2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .fetch_one(&mut *transaction) + .await?; + let state = decode_state(row.try_get("state")?)?; + let mut generation = decode_generation(row.try_get("generation")?)?; + if state == ProtectedObjectAuthorityState::Legacy { + generation = generation.checked_add(1).ok_or_else(|| { + DbError::InvalidData("protected object authority generation exhausted".into()) + })?; + let generation_i64 = i64::try_from(generation).map_err(|_| { + DbError::InvalidData("protected object authority generation exhausted".into()) + })?; + sqlx::query( + "UPDATE protected_object_authority SET state = 'importing', \ + generation = $3, started_at = clock_timestamp(), completed_at = NULL, \ + imported_objects = 0, inventory_sha256 = NULL, \ + updated_at = clock_timestamp() \ + WHERE community_id = $1 AND surface = $2", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .bind(generation_i64) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(ProtectedObjectAuthority { + generation, + state: if state == ProtectedObjectAuthorityState::Legacy { + ProtectedObjectAuthorityState::Importing + } else { + state + }, + imported_objects: None, + inventory_sha256: None, + }) + } + + /// Finalize one exact import generation after a complete parity pass. + pub async fn finalize_protected_object_import( + &self, + community: CommunityId, + surface: ProtectedObjectSurface, + generation: u64, + imported_objects: u64, + inventory_sha256: &str, + ) -> Result<()> { + validate_inventory_digest(inventory_sha256)?; + let generation = i64::try_from(generation).map_err(|_| { + DbError::InvalidData("protected object authority generation is invalid".into()) + })?; + let imported_objects = i64::try_from(imported_objects) + .map_err(|_| DbError::InvalidData("protected object import count is invalid".into()))?; + let result = sqlx::query( + "UPDATE protected_object_authority SET state = 'postgresql', \ + imported_objects = $4, inventory_sha256 = $5, \ + completed_at = clock_timestamp(), updated_at = clock_timestamp() \ + WHERE community_id = $1 AND surface = $2 AND state = 'importing' \ + AND generation = $3", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .bind(generation) + .bind(imported_objects) + .bind(inventory_sha256) + .execute(&self.pool) + .await?; + if result.rows_affected() != 1 { + let current = self.protected_object_authority(community, surface).await?; + if current.state != ProtectedObjectAuthorityState::PostgreSql + || current.generation != u64::try_from(generation).unwrap_or_default() + || current.imported_objects + != Some(u64::try_from(imported_objects).unwrap_or_default()) + || current.inventory_sha256.as_deref() != Some(inventory_sha256) + { + return Err(DbError::InvalidData( + "protected object import generation changed".into(), + )); + } + } + Ok(()) + } + + /// Acquire a transaction-held fence spanning a legacy pointer/sidecar write. + pub async fn begin_legacy_visibility_write( + &self, + community: CommunityId, + surface: ProtectedObjectSurface, + ) -> Result { + let mut transaction = self.begin_transaction().await?; + ensure_row(&mut transaction, community, surface).await?; + let state: String = sqlx::query_scalar( + "SELECT state FROM protected_object_authority \ + WHERE community_id = $1 AND surface = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .fetch_one(&mut *transaction) + .await?; + if decode_state(&state)? != ProtectedObjectAuthorityState::Legacy { + return Err(DbError::InvalidData( + "legacy object visibility is no longer writable".into(), + )); + } + Ok(LegacyVisibilityWrite { transaction }) + } +} + +/// Transaction lock held across one actual legacy visibility write. +pub struct LegacyVisibilityWrite { + transaction: Transaction<'static, Postgres>, +} + +impl LegacyVisibilityWrite { + /// Commit after the pointer or sidecar has become visible. + pub async fn commit(self) -> Result<()> { + self.transaction.commit().await.map_err(Into::into) + } +} + +/// Require an exact authority state inside a caller-owned transaction. +pub async fn require_protected_object_authority( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + surface: ProtectedObjectSurface, + expected: ProtectedObjectAuthorityState, +) -> Result<()> { + ensure_row(transaction, community, surface).await?; + let state: String = sqlx::query_scalar( + "SELECT state FROM protected_object_authority \ + WHERE community_id = $1 AND surface = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(surface.as_str()) + .fetch_one(&mut **transaction) + .await?; + if decode_state(&state)? != expected { + return Err(DbError::InvalidData( + "protected object visibility authority is unavailable".into(), + )); + } + Ok(()) +} + +fn validate_inventory_digest(value: &str) -> Result<()> { + if value.len() != 64 + || !value + .chars() + .all(|character| matches!(character, '0'..='9' | 'a'..='f')) + { + return Err(DbError::InvalidData( + "protected object inventory digest is invalid".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + use sqlx::PgPool; + use uuid::Uuid; + + #[test] + fn authority_state_decoder_is_closed() { + assert_eq!( + decode_state("postgresql").expect("known state"), + ProtectedObjectAuthorityState::PostgreSql + ); + assert!(decode_state("fallback").is_err()); + } + + #[test] + fn inventory_digest_is_strict_lowercase_sha256() { + assert!(validate_inventory_digest(&"a".repeat(64)).is_ok()); + assert!(validate_inventory_digest(&"A".repeat(64)).is_err()); + assert!(validate_inventory_digest("short").is_err()); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn cutover_waits_for_legacy_commit_and_never_reverses() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + let pool = PgPool::connect(&database_url).await.expect("test database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("migrated test database"); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("visibility-{}.example", id.simple())) + .execute(&pool) + .await + .expect("community"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(id); + + let guard = db + .begin_legacy_visibility_write(community, ProtectedObjectSurface::Git) + .await + .expect("legacy writer"); + let contender_pool = PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("SET lock_timeout = '100ms'") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("contender database"); + let contender = Db::from_pool(contender_pool); + let blocked = contender + .begin_protected_object_import(community, ProtectedObjectSurface::Git) + .await + .expect_err("cutover must wait for the legacy writer"); + assert!( + matches!( + blocked, + DbError::Sqlx(sqlx::Error::Database(ref error)) + if error.code().as_deref() == Some("55P03") + ), + "the real cutover row lock must block: {blocked:?}" + ); + guard.commit().await.expect("legacy commit"); + let importing = db + .begin_protected_object_import(community, ProtectedObjectSurface::Git) + .await + .expect("begin import"); + assert_eq!(importing.state, ProtectedObjectAuthorityState::Importing); + assert!(db + .begin_legacy_visibility_write(community, ProtectedObjectSurface::Git) + .await + .is_err()); + + db.finalize_protected_object_import( + community, + ProtectedObjectSurface::Git, + importing.generation, + 0, + &"0".repeat(64), + ) + .await + .expect("finalize"); + let finished = db + .begin_protected_object_import(community, ProtectedObjectSurface::Git) + .await + .expect("monotonic retry"); + assert_eq!(finished.state, ProtectedObjectAuthorityState::PostgreSql); + assert_eq!(finished.generation, importing.generation); + } +} diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 530ce69c90..9e5c565db7 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Media storage, validation, and thumbnail generation for Buzz" [dependencies] +buzz-auth = { workspace = true } buzz-core = { workspace = true } nostr = { workspace = true } serde = { workspace = true } diff --git a/crates/buzz-media/src/auth.rs b/crates/buzz-media/src/auth.rs index c6fff2be47..17e2b494cf 100644 --- a/crates/buzz-media/src/auth.rs +++ b/crates/buzz-media/src/auth.rs @@ -1,241 +1,71 @@ //! Blossom kind:24242 auth verification (BUD-11 compliant). use crate::error::MediaError; - -/// Blossom kind:24242 verbs Buzz currently accepts. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlossomVerb { - Upload, - Get, -} - -impl BlossomVerb { - fn as_str(self) -> &'static str { - match self { - Self::Upload => "upload", - Self::Get => "get", - } +pub use buzz_auth::blossom::BlossomVerb; + +fn map_auth_error(error: buzz_auth::blossom::BlossomAuthError) -> MediaError { + use buzz_auth::blossom::BlossomAuthError; + + match error { + BlossomAuthError::InvalidSignature => MediaError::InvalidSignature, + BlossomAuthError::InvalidAuthKind => MediaError::InvalidAuthKind, + BlossomAuthError::InvalidAuthEvent => MediaError::InvalidAuthEvent, + BlossomAuthError::InvalidAuthVerb => MediaError::InvalidAuthVerb, + BlossomAuthError::MissingTag(tag) => MediaError::MissingTag(tag), + BlossomAuthError::TokenExpired => MediaError::TokenExpired, + BlossomAuthError::TimestampOutOfWindow => MediaError::TimestampOutOfWindow, + BlossomAuthError::ServerMismatch => MediaError::ServerMismatch, + BlossomAuthError::HashMismatch => MediaError::HashMismatch, + BlossomAuthError::InsufficientScope => MediaError::InsufficientScope, } } -/// Verify common kind:24242 Blossom auth event validity: -/// 1. Schnorr signature -/// 2. kind == 24242 -/// 3. `t` tag matches `verb` -/// 4. `expiration` tag in the future -/// 5. `created_at` in the past (with 5s clock-skew tolerance) -/// 6. If `server` tags present, our domain must appear in at least one -/// -/// Does NOT check verb-specific scope tags (`x` for upload, `x` OR `server` -/// for get). Call this BEFORE trusting the event's pubkey for scope resolution. +/// Verify common kind:24242 Blossom auth event validity for one exact verb. pub fn verify_blossom_auth_event_for_verb( auth_event: &nostr::Event, verb: BlossomVerb, server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { - // 1. Verify Schnorr signature - auth_event - .verify() - .map_err(|_| MediaError::InvalidSignature)?; - - // 2. Kind must be 24242 - if auth_event.kind.as_u16() != 24242 { - return Err(MediaError::InvalidAuthKind); - } - - // 2b. Content must be non-empty (BUD-11: "human readable string") - if auth_event.content.trim().is_empty() { - return Err(MediaError::InvalidAuthEvent); - } - - let mut found_t = false; - let mut found_exp = false; - let mut server_tags: Vec<&str> = Vec::new(); - let mut exp_value: u64 = 0; - - for tag in auth_event.tags.iter() { - let kind = tag.kind().to_string(); - match kind.as_str() { - "t" => { - if let Some(v) = tag.content() { - if v != verb.as_str() { - return Err(MediaError::InvalidAuthVerb); - } - found_t = true; - } - } - "expiration" => { - if let Some(v) = tag.content() { - exp_value = v.parse().unwrap_or(0); - found_exp = true; - } - } - "server" => { - if let Some(v) = tag.content() { - server_tags.push(v); - } - } - _ => {} - } - } - - // 3. t tag required - if !found_t { - return Err(MediaError::MissingTag("t")); - } - - // 4. Expiration must exist and be in the future - if !found_exp { - return Err(MediaError::MissingTag("expiration")); - } - let now = nostr::Timestamp::now().as_secs(); - if exp_value <= now { - return Err(MediaError::TokenExpired); - } - - // 5. created_at must be recent: not in the future (5s tolerance) and not - // older than 10 minutes. This bounds the replay window — even if the - // expiration tag allows a longer lifetime, the token must have been - // freshly minted. - let created = auth_event.created_at.as_secs(); - if created > now + 5 { - return Err(MediaError::TimestampOutOfWindow); - } - if now > created + max_age_secs { - return Err(MediaError::TimestampOutOfWindow); - } - - // 6. Server tag enforcement (BUD-11 §5): if server tags present, our host must appear. - // - // `server_domain` is the host this request was bound to — the per-request - // tenant host (`TenantContext::host()`), NOT a single process-global domain. - // A relay process serves many tenant hosts; validating against one global - // host would 401 every non-primary tenant's server-tagged client (the stock - // CLI always tags its configured relay host). Comparison is done under the - // shared [`normalize_host`] rule so a tag and the bound host agree by - // construction across case, trailing dot, default ports, and an optional - // URL scheme/path — exactly as every other host seam resolves tenants. - // - // Fail closed: if the bound host is unknown, reject tokens that carry server - // tags rather than silently accepting them. - if !server_tags.is_empty() { - match server_domain { - Some(domain) => { - let want = normalize_server_host(domain); - let matches = server_tags - .iter() - .any(|tag| normalize_server_host(tag) == want); - if !matches { - return Err(MediaError::ServerMismatch); - } - } - None => { - // Server tags present but we don't know our own host — reject. - return Err(MediaError::ServerMismatch); - } - } - } - - Ok(()) + buzz_auth::blossom::verify_blossom_auth_event_for_verb( + auth_event, + verb, + server_domain, + max_age_secs, + ) + .map_err(map_auth_error) } -/// Verify common upload auth event validity. -/// -/// Kept as the upload-shaped public wrapper for existing callers; new verb-aware -/// code should prefer [`verify_blossom_auth_event_for_verb`]. +/// Verify common upload auth event validity without checking the blob hash. pub fn verify_blossom_auth_event( auth_event: &nostr::Event, server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs) -} - -/// Normalize a Blossom `server` tag value (or a bound tenant host) into the -/// canonical host form used as the community lookup key. -/// -/// A `server` tag may be a bare authority (`relay.example:3100`, what the stock -/// CLI emits) or a full URL (`https://relay.example/`). We strip an optional -/// scheme and path down to the authority, then apply the one shared -/// [`buzz_core::tenant::normalize_host`] rule so the comparison agrees with how -/// the WS/HTTP/git doors resolve tenants. -fn normalize_server_host(value: &str) -> String { - let authority = match value.split_once("://") { - Some((_scheme, rest)) => rest.split('/').next().unwrap_or(rest), - None => value.split('/').next().unwrap_or(value), - }; - buzz_core::tenant::normalize_host(authority) + buzz_auth::blossom::verify_blossom_auth_event(auth_event, server_domain, max_age_secs) + .map_err(map_auth_error) } -/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check. -/// -/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one -/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches"). +/// Verify a kind:24242 upload event including the exact `x` tag blob hash. pub fn verify_blossom_upload_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb( - auth_event, - BlossomVerb::Upload, - server_domain, - max_age_secs, - )?; - - // At least one x tag must match the body sha256 (BUD-11 §6) - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); - - if !has_matching_x { - return Err(MediaError::HashMismatch); - } - - Ok(()) + buzz_auth::blossom::verify_blossom_upload_auth(auth_event, sha256, server_domain, max_age_secs) + .map_err(map_auth_error) } -/// Verify a kind:24242 Blossom get auth event for one requested blob. -/// -/// BUD-01 permits either blob-scoped authorization (`x` tag matches `sha256`) -/// or server-scoped authorization (`server` tag matches this relay host). The -/// latter intentionally grants reads for all blobs on the host until expiration; -/// callers must still apply relay membership after this verifier returns. +/// Verify a kind:24242 download event for one exact blob and server. pub fn verify_blossom_get_auth( auth_event: &nostr::Event, sha256: &str, server_domain: Option<&str>, max_age_secs: u64, ) -> Result<(), MediaError> { - verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?; - - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256))); - - let has_matching_server = match server_domain { - Some(domain) => { - let want = normalize_server_host(domain); - auth_event.tags.iter().any(|tag| { - tag.kind().to_string() == "server" - && tag - .content() - .map(|value| normalize_server_host(value) == want) - .unwrap_or(false) - }) - } - None => false, - }; - - if !has_matching_x && !has_matching_server { - return Err(MediaError::InsufficientScope); - } - - Ok(()) + buzz_auth::blossom::verify_blossom_get_auth(auth_event, sha256, server_domain, max_age_secs) + .map_err(map_auth_error) } #[cfg(test)] diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 67896d4ef2..596d9fde03 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -21,7 +21,10 @@ pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; -pub use upload::{process_file_upload, process_upload, process_video_upload}; +pub use upload::{ + process_file_upload, process_upload, process_video_upload, PreparedUpload, UploadCommitGuard, + UploadPublicationMode, +}; pub use upload_record::{ parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, UploadRecord, UPLOAD_RECORD_VERSION, diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index cbf980201f..bf8f6031c3 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -7,6 +7,15 @@ use buzz_core::tenant::{CommunityId, TenantContext}; use crate::config::{MediaConfig, S3AddressingStyle}; use crate::error::MediaError; + +/// Result of an immutable, create-only object write. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CreateOnlyOutcome { + /// This caller created the object. + Created, + /// An object already existed at the key. + AlreadyExists, +} use bytes::Bytes; use s3::creds::Credentials; use s3::{Bucket, Region}; @@ -80,6 +89,37 @@ impl MediaStorage { Ok(()) } + /// Create an immutable object without overwriting an existing value. + pub async fn put_create_only( + &self, + key: &str, + bytes: &[u8], + content_type: &str, + ) -> Result { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::IF_NONE_MATCH, + axum::http::HeaderValue::from_static("*"), + ); + match self + .bucket + .put_object_with_content_type_and_headers(key, bytes, content_type, Some(headers)) + .await + { + Ok(response) if (200..300).contains(&response.status_code()) => { + Ok(CreateOnlyOutcome::Created) + } + Err(s3::error::S3Error::HttpFailWithBody(412, _)) => { + Ok(CreateOnlyOutcome::AlreadyExists) + } + Ok(response) => Err(MediaError::StorageError(format!( + "create-only object write returned status {}", + response.status_code() + ))), + Err(error) => Err(MediaError::StorageError(error.to_string())), + } + } + /// Stream a file from disk into S3 without loading it into RAM. /// /// Uses rust-s3's `put_object_stream_with_content_type` which reads from @@ -113,6 +153,15 @@ impl MediaStorage { } } + /// Retrieve an object's bytes, returning `None` only for an absent key. + pub async fn get_optional(&self, key: &str) -> Result>, MediaError> { + match self.bucket.get_object(key).await { + Ok(response) => Ok(Some(response.to_vec())), + Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None), + Err(error) => Err(MediaError::StorageError(error.to_string())), + } + } + /// Retrieve a byte range from an object via S3-native `Range` GET. /// /// `start` and `end` are inclusive byte offsets. Only the requested slice @@ -246,16 +295,24 @@ impl MediaStorage { &self, continuation_token: Option, max_keys: usize, + ) -> Result { + self.list_page_with_prefix(String::new(), continuation_token, max_keys) + .await + } + + /// One bounded page under an exact object-key prefix. + /// + /// Migration callers use this to inventory one server-resolved community + /// without observing or loading another community's metadata sidecars. + pub async fn list_page_with_prefix( + &self, + prefix: String, + continuation_token: Option, + max_keys: usize, ) -> Result { let (result, _status) = self .bucket - .list_page( - String::new(), - None, - continuation_token, - None, - Some(max_keys), - ) + .list_page(prefix, None, continuation_token, None, Some(max_keys)) .await?; Ok(crate::bucket_index::Page { objects: result diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 524b033280..f2493eb4f0 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -12,11 +12,55 @@ use crate::storage::{BlobMeta, MediaStorage}; use crate::thumbnail::generate_image_metadata_sync; use crate::types::BlobDescriptor; use crate::upload_record::{record_upload_event, UploadAttribution, UploadEventFacts}; + +/// Read-only authorization checkpoint invoked immediately before durable media effects. +pub trait UploadCommitGuard: Send + Sync { + /// Deny if the upload no longer has current authority. + fn revalidate(&self) -> Result<(), MediaError>; +} use crate::validation::{ looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content, validate_video_file, }; +/// Visibility contract selected by the relay before any upload side effect. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UploadPublicationMode { + /// Preserve the existing sidecar and upload-record publication contract. + Legacy, + /// Stage only immutable objects; PostgreSQL decides protected visibility. + ProtectedStaging, +} + +/// Validated immutable objects and metadata awaiting an authoritative publish. +#[derive(Debug, Clone)] +pub struct PreparedUpload { + pub descriptor: BlobDescriptor, + pub metadata: BlobMeta, + pub object_key: String, + pub thumbnail_key: Option, +} + +/// Inputs for one buffered upload request. +pub struct BufferedUploadRequest<'a> { + /// Object storage used for immutable staging. + pub storage: &'a MediaStorage, + /// Media validation and size limits. + pub config: &'a MediaConfig, + /// Server-resolved tenant context. + pub ctx: &'a TenantContext, + /// Authenticated Blossom upload event. + pub auth_event: &'a nostr::Event, + /// Bounded upload bytes. + pub body: Bytes, + /// Optional upload-event attribution for the legacy path. + pub attribution: Option, + /// Authority checkpoint used immediately before durable effects. + pub commit_guard: &'a dyn UploadCommitGuard, + /// Visibility contract selected by the relay. + pub publication_mode: UploadPublicationMode, +} + /// Shared buffered-upload pipeline for the image and generic-file paths. /// /// Both paths are identical except for two steps, which are injected: @@ -49,17 +93,19 @@ struct BufferedUploadInput<'a> { auth_event: &'a nostr::Event, body: Bytes, attribution: Option, + commit_guard: &'a dyn UploadCommitGuard, + publication_mode: UploadPublicationMode, } async fn process_buffered_upload( input: BufferedUploadInput<'_>, validate: V, prepare_metadata: M, -) -> Result +) -> Result where V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static, M: FnOnce(MetadataInput) -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future), MediaError>>, { let BufferedUploadInput { storage, @@ -68,6 +114,8 @@ where auth_event, body, attribution, + commit_guard, + publication_mode, } = input; // CPU-bound: validate content, compute hash, verify auth. @@ -95,13 +143,14 @@ where // sidecar exists but the blob is missing, fall through to re-upload. let sidecar_exists = storage.head(&meta_key).await?; let blob_exists = storage.head(&key).await?; - if sidecar_exists && blob_exists { + if publication_mode == UploadPublicationMode::Legacy && sidecar_exists && blob_exists { let meta = storage.get_sidecar(ctx, &sha256).await?; // A re-upload of known bytes is still a distinct upload *event*: no // blob PUT happens, so without this record the uploader would be // invisible to the moderation pipeline (and takedown re-uploads // would go unscanned). if let Some(attribution) = &attribution { + commit_guard.revalidate()?; record_upload_event( storage, ctx, @@ -117,15 +166,20 @@ where ) .await?; } - return Ok(build_descriptor( - config, - &sha256, - &ext, - &mime, - body.len() as u64, - Some(&meta), - meta.uploaded_at, - )); + return Ok(PreparedUpload { + descriptor: build_descriptor( + config, + &sha256, + &ext, + &mime, + body.len() as u64, + Some(&meta), + meta.uploaded_at, + ), + metadata: meta, + object_key: key, + thumbnail_key: sidecar_exists.then(|| format!("{sha256}.thumb.jpg")), + }); } // Compute uploaded_at once — single source of truth for sidecar and response. @@ -138,9 +192,12 @@ where // content-addressed and bounded by the upload size limit, so the storage // cost is negligible. A V2 background GC job can sweep blobs with no // matching sidecar after a grace period. - storage.put(&key, &body, &mime).await?; + commit_guard.revalidate()?; + if !blob_exists { + storage.put(&key, &body, &mime).await?; + } - let meta = match prepare_metadata(MetadataInput { + let (meta, thumbnail_key) = match prepare_metadata(MetadataInput { sha256: sha256.clone(), ext: ext.clone(), mime: mime.clone(), @@ -159,33 +216,42 @@ where // The moderation record precedes the sidecar publish gate. If this write // fails, the blob and any thumbnail remain orphaned but the media cannot be // served. Conversely, record existence still implies those objects exist. - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256, - ext: &ext, - mime: &mime, - size: body.len() as u64, - uploaded_at, - }, - ) - .await?; + if publication_mode == UploadPublicationMode::Legacy { + if let Some(attribution) = &attribution { + commit_guard.revalidate()?; + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256, + ext: &ext, + mime: &mime, + size: body.len() as u64, + uploaded_at, + }, + ) + .await?; + } + commit_guard.revalidate()?; + storage.put_sidecar(ctx, &sha256, &meta).await?; } - storage.put_sidecar(ctx, &sha256, &meta).await?; - Ok(build_descriptor( - config, - &sha256, - &ext, - &mime, - body.len() as u64, - Some(&meta), - uploaded_at, - )) + Ok(PreparedUpload { + descriptor: build_descriptor( + config, + &sha256, + &ext, + &mime, + body.len() as u64, + Some(&meta), + uploaded_at, + ), + metadata: meta, + object_key: key, + thumbnail_key, + }) } /// Inputs handed to a buffered-upload metadata builder, after the shared @@ -205,13 +271,18 @@ struct MetadataInput { /// This is the image path — body is already fully buffered in RAM. Do NOT use /// this for video uploads; use [`process_video_upload`] instead. pub async fn process_upload( - storage: &MediaStorage, - config: &MediaConfig, - ctx: &TenantContext, - auth_event: &nostr::Event, - body: Bytes, - attribution: Option, -) -> Result { + request: BufferedUploadRequest<'_>, +) -> Result { + let BufferedUploadRequest { + storage, + config, + ctx, + auth_event, + body, + attribution, + commit_guard, + publication_mode, + } = request; process_buffered_upload( BufferedUploadInput { storage, @@ -220,13 +291,17 @@ pub async fn process_upload( auth_event, body, attribution, + commit_guard, + publication_mode, }, |bytes, cfg| { let mime = validate_content(bytes, cfg)?; let ext = mime_to_ext(&mime).to_string(); Ok((mime, ext)) }, - |input| async move { prepare_image_metadata(storage, config, input).await }, + |input| async move { + prepare_image_metadata(storage, config, input, commit_guard, publication_mode).await + }, ) .await } @@ -243,13 +318,18 @@ pub async fn process_upload( /// The resulting blob is served with `Content-Disposition: attachment`, so the /// client always downloads it rather than rendering it inline. pub async fn process_file_upload( - storage: &MediaStorage, - config: &MediaConfig, - ctx: &TenantContext, - auth_event: &nostr::Event, - body: Bytes, - attribution: Option, -) -> Result { + request: BufferedUploadRequest<'_>, +) -> Result { + let BufferedUploadRequest { + storage, + config, + ctx, + auth_event, + body, + attribution, + commit_guard, + publication_mode, + } = request; process_buffered_upload( BufferedUploadInput { storage, @@ -258,6 +338,8 @@ pub async fn process_file_upload( auth_event, body, attribution, + commit_guard, + publication_mode, }, |bytes, cfg| validate_file_content(bytes, cfg), |input| async move { @@ -272,7 +354,7 @@ pub async fn process_file_upload( uploaded_at: input.uploaded_at, duration_secs: None, }; - Ok(meta) + Ok((meta, None)) }, ) .await @@ -289,6 +371,9 @@ pub async fn process_file_upload( /// 5. Writes a sidecar with `duration_secs` (no thumbnail — desktop handles that). /// /// Returns a [`BlobDescriptor`] with the `duration` field populated. +// The guard remains an explicit trust-boundary argument so callers cannot +// accidentally choose an unguarded upload variant before the durable write. +#[allow(clippy::too_many_arguments)] pub async fn process_video_upload( storage: &MediaStorage, config: &MediaConfig, @@ -297,7 +382,9 @@ pub async fn process_video_upload( body_stream: impl futures_core::Stream> + Send + 'static, content_length: Option, attribution: Option, -) -> Result { + commit_guard: &dyn UploadCommitGuard, + publication_mode: UploadPublicationMode, +) -> Result { // --- 1. Stream body to temp file, compute SHA-256 incrementally --- let tmp = tempfile::NamedTempFile::new().map_err(|e| MediaError::Io(e.to_string()))?; let tmp_path = tmp.path().to_path_buf(); @@ -429,11 +516,12 @@ pub async fn process_video_upload( // --- 5. Idempotency check --- let sidecar_exists = storage.head(&meta_key).await?; let blob_exists = storage.head(&key).await?; - if sidecar_exists && blob_exists { + if publication_mode == UploadPublicationMode::Legacy && sidecar_exists && blob_exists { let meta = storage.get_sidecar(ctx, &sha256_hex).await?; // Re-upload of known bytes: still a distinct upload event — see the // buffered path's short-circuit for the rationale. if let Some(attribution) = &attribution { + commit_guard.revalidate()?; record_upload_event( storage, ctx, @@ -449,21 +537,29 @@ pub async fn process_video_upload( ) .await?; } - return Ok(build_descriptor( - config, - &sha256_hex, - ext, - &mime, - file_size, - Some(&meta), - meta.uploaded_at, - )); + return Ok(PreparedUpload { + descriptor: build_descriptor( + config, + &sha256_hex, + ext, + &mime, + file_size, + Some(&meta), + meta.uploaded_at, + ), + metadata: meta, + object_key: key, + thumbnail_key: None, + }); } let uploaded_at = chrono::Utc::now().timestamp(); // --- 6. Stream blob from temp file to S3 --- - storage.put_file(&key, &tmp_path, &mime).await?; + commit_guard.revalidate()?; + if !blob_exists { + storage.put_file(&key, &tmp_path, &mime).await?; + } drop(tmp); // Free temp file disk space immediately after S3 upload. // --- 7. Build metadata (no thumbnail for video — desktop handles that) --- @@ -479,33 +575,42 @@ pub async fn process_video_upload( }; // Record before publishing the sidecar serve gate. See the buffered path. - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256_hex, - ext, - mime: &mime, - size: file_size, - uploaded_at, - }, - ) - .await?; + if publication_mode == UploadPublicationMode::Legacy { + if let Some(attribution) = &attribution { + commit_guard.revalidate()?; + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &sha256_hex, + ext, + mime: &mime, + size: file_size, + uploaded_at, + }, + ) + .await?; + } + commit_guard.revalidate()?; + storage.put_sidecar(ctx, &sha256_hex, &meta).await?; } - storage.put_sidecar(ctx, &sha256_hex, &meta).await?; - Ok(build_descriptor( - config, - &sha256_hex, - ext, - &mime, - file_size, - Some(&meta), - uploaded_at, - )) + Ok(PreparedUpload { + descriptor: build_descriptor( + config, + &sha256_hex, + ext, + &mime, + file_size, + Some(&meta), + uploaded_at, + ), + metadata: meta, + object_key: key, + thumbnail_key: None, + }) } /// Generate thumbnail and metadata without publishing the sidecar serve gate. @@ -514,7 +619,9 @@ async fn prepare_image_metadata( storage: &MediaStorage, config: &MediaConfig, input: MetadataInput, -) -> Result { + commit_guard: &dyn UploadCommitGuard, + publication_mode: UploadPublicationMode, +) -> Result<(BlobMeta, Option), MediaError> { let body_ref = input.body.clone(); let mime_ref = input.mime.clone(); let ext_ref = input.ext.clone(); @@ -528,12 +635,22 @@ async fn prepare_image_metadata( meta.uploaded_at = input.uploaded_at; - if let Some(ref tb) = thumb_bytes { - let thumb_key = format!("{}.thumb.jpg", input.sha256); + let thumbnail_key = if let Some(ref tb) = thumb_bytes { + let thumb_key = match publication_mode { + UploadPublicationMode::Legacy => format!("{}.thumb.jpg", input.sha256), + UploadPublicationMode::ProtectedStaging => { + let digest = hex::encode(Sha256::digest(tb)); + format!("_objects/thumbnails/{digest}.jpg") + } + }; + commit_guard.revalidate()?; storage.put(&thumb_key, tb, "image/jpeg").await?; - } + Some(thumb_key) + } else { + None + }; - Ok(meta) + Ok((meta, thumbnail_key)) } fn build_descriptor( diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index 50bb36d818..cb58d11b42 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -158,6 +158,7 @@ pub struct PublishLimits { struct PublishOptions { limits: PublishLimits, compaction_threshold: usize, + publish_pointer: bool, } struct CompactedPack { @@ -258,6 +259,15 @@ impl ParentState { parent, } } + + /// Build parent state from a PostgreSQL-selected publication. + pub fn from_published(digest: String, parent: Manifest) -> Self { + Self { + if_match: None, + parent_digest: Some(digest), + parent, + } + } } /// Read `refs/*` + symbolic-HEAD from the workspace. @@ -1013,6 +1023,36 @@ pub async fn cas_publish( PublishOptions { limits, compaction_threshold: PACK_COMPACTION_THRESHOLD, + publish_pointer: true, + }, + ) + .await +} + +/// Stage immutable packs and a validated manifest without publishing a pointer. +/// +/// The caller must make the returned manifest digest visible through a +/// PostgreSQL publication CAS in the same protected operation transaction. +pub async fn prepare_publish( + store: &GitStore, + ctx: &TenantContext, + repo_path: &Path, + owner: &str, + repo: &str, + parent_state: &ParentState, + limits: PublishLimits, +) -> Result { + cas_publish_inner( + store, + ctx, + repo_path, + owner, + repo, + parent_state, + PublishOptions { + limits, + compaction_threshold: PACK_COMPACTION_THRESHOLD, + publish_pointer: false, }, ) .await @@ -1208,6 +1248,22 @@ async fn cas_publish_inner( }; let manifest_digest = digest_from_manifest_key(&manifest_key)?; + if !options.publish_pointer { + if let Some(observation) = &compaction_observation { + record_compaction( + "staged", + observation.started_at, + observation.packs_before, + Some(observation.packs_after), + Some(observation.compacted_bytes), + ); + } + return Ok(CasSuccess { + manifest: m_after, + manifest_key, + }); + } + // Step 7: CAS the pointer. let precond = match &parent_state.if_match { Some(e) => Precond::IfMatch(e.clone()), @@ -1763,6 +1819,7 @@ mod tests { let test_options = PublishOptions { limits, compaction_threshold: 2, + publish_pointer: true, }; let success = cas_publish_inner( &store, diff --git a/crates/buzz-relay/src/api/git/hook.rs b/crates/buzz-relay/src/api/git/hook.rs index e8e2c4d342..a819da4495 100644 --- a/crates/buzz-relay/src/api/git/hook.rs +++ b/crates/buzz-relay/src/api/git/hook.rs @@ -25,6 +25,7 @@ use tracing::{error, info}; /// - `BUZZ_REPO_ID` — repo identifier (d-tag) /// - `BUZZ_COMMUNITY_ID` — server-resolved community UUID for the git HTTP request /// - `BUZZ_PUSHER_PUBKEY` — authenticated pusher's hex pubkey +/// - `BUZZ_POLICY_FENCE_PATH` — relay-owned path for the exact allowed-policy receipt /// /// Git sets automatically (quarantine): /// - `GIT_OBJECT_DIRECTORY` — quarantine object store @@ -47,6 +48,7 @@ ZERO="0000000000000000000000000000000000000000" : "${BUZZ_PUSHER_PUBKEY:?error: BUZZ_PUSHER_PUBKEY not set}" : "${BUZZ_HOOK_URL:?error: BUZZ_HOOK_URL not set}" : "${BUZZ_HOOK_SECRET:?error: BUZZ_HOOK_SECRET not set}" +: "${BUZZ_POLICY_FENCE_PATH:?error: BUZZ_POLICY_FENCE_PATH not set}" WORK_DIR=$(mktemp -d) || { echo "error: cannot create temp dir" >&2; exit 1; } REFS_FILE="$WORK_DIR/refs" @@ -141,6 +143,13 @@ if [ "$HTTP_CODE" != "200" ]; then exit 1 fi +# Preserve the exact hook decision for transaction-owned commit-time locking. +# Failure to hand the receipt back rejects the push before any publication. +cp -- "$RESP_FILE" "$BUZZ_POLICY_FENCE_PATH" || { + echo "error: push authorization receipt could not be retained" >&2 + exit 1 +} + exit 0 "#; diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 3ce809d18f..73408511ef 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -149,6 +149,19 @@ pub async fn hydrate_for_read( result } +/// Hydrate from a PostgreSQL-selected manifest digest. +/// +/// The object-store pointer is deliberately bypassed; callers must obtain the +/// digest from the active publication row in the server-resolved domain. +pub async fn hydrate_for_published_read( + store: &GitStore, + manifest_digest: &str, + options: HydrationOptions<'_>, +) -> Result { + let manifest = load_manifest_by_digest(store, manifest_digest).await?; + materialize_manifest(store, &manifest, options).await +} + async fn hydrate_for_read_inner( store: &GitStore, ctx: &TenantContext, @@ -178,6 +191,26 @@ pub async fn load_manifest_for_read( .map(|(_etag, _digest, manifest)| manifest)) } +/// Load and verify a PostgreSQL-selected immutable manifest. +pub async fn load_manifest_by_digest( + store: &GitStore, + digest: &str, +) -> Result { + if digest.len() != 64 + || !digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(HydrateError::InvalidPointer); + } + let manifest_key = format!("manifests/{digest}"); + let manifest_bytes = + get_verified_limited(store, &manifest_key, digest, MAX_MANIFEST_BYTES).await?; + let manifest = Manifest::from_bytes(&manifest_bytes)?; + manifest.validate()?; + Ok(manifest) +} + async fn init_bare_repo(path: &Path) -> Result<(), HydrateError> { run_git(path, &["init", "--bare", "--quiet"]).await?; run_git(path, &["symbolic-ref", "HEAD", "refs/heads/main"]).await @@ -239,6 +272,40 @@ pub async fn hydrate_for_write( } } +/// Hydrate a write workspace from PostgreSQL-authoritative publication state. +pub async fn hydrate_for_published_write( + store: &GitStore, + manifest_digest: Option<&str>, + options: HydrationOptions<'_>, +) -> Result<(HydratedRepo, ParentState), HydrateError> { + match manifest_digest { + Some(digest) => { + let manifest = load_manifest_by_digest(store, digest).await?; + let repo = materialize_manifest(store, &manifest, options).await?; + Ok(( + repo, + ParentState::from_published(digest.to_owned(), manifest), + )) + } + None => { + let tempdir = TempDir::new_in(options.scratch_dir).map_err(|error| { + HydrateError::Hydrate(format!("tempdir in {:?}: {error}", options.scratch_dir)) + })?; + let path = tempdir.path().to_path_buf(); + init_bare_repo(&path).await?; + Ok(( + HydratedRepo { + _tempdir: tempdir, + path, + hydrated_bytes: 0, + hydrated_packs: 0, + }, + ParentState::fresh(), + )) + } + } +} + /// Resolve the pointer to its `(ETag, digest, verified Manifest)` triple. /// /// `Ok(None)` if the pointer is absent (caller decides 404 vs first-push @@ -261,11 +328,7 @@ async fn load_pointer( if digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()) { return Err(HydrateError::InvalidPointer); } - let manifest_key = format!("manifests/{digest}"); - let manifest_bytes = - get_verified_limited(store, &manifest_key, &digest, MAX_MANIFEST_BYTES).await?; - let manifest = Manifest::from_bytes(&manifest_bytes)?; - manifest.validate()?; + let manifest = load_manifest_by_digest(store, &digest).await?; Ok(Some((etag, digest, manifest))) } diff --git a/crates/buzz-relay/src/api/git/migration.rs b/crates/buzz-relay/src/api/git/migration.rs new file mode 100644 index 0000000000..c3cf679faf --- /dev/null +++ b/crates/buzz-relay/src/api/git/migration.rs @@ -0,0 +1,461 @@ +//! Validated one-way migration from legacy Git pointers to PostgreSQL authority. + +use std::collections::BTreeMap; + +use buzz_core::tenant::TenantContext; +use buzz_db::protected_visibility::{ProtectedObjectAuthorityState, ProtectedObjectSurface}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::api::git::hydrate::{hydrate_for_published_read, load_manifest_by_digest}; +use crate::api::git::manifest::pointer_key; +use crate::state::AppState; + +const SENTINEL_FORMAT_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CutoverSentinel { + format_version: u32, + community_id: uuid::Uuid, + surface: String, + generation: u64, + imported_objects: u64, + inventory_sha256: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PreparationDisposition { + Begin, + Resume, + Verify, +} + +fn preparation_disposition( + authority: &buzz_db::protected_visibility::ProtectedObjectAuthority, + sentinel: Option<&CutoverSentinel>, +) -> anyhow::Result { + match authority.state { + ProtectedObjectAuthorityState::Legacy => { + if sentinel.is_some() { + anyhow::bail!("Git cutover sentinel exists but PostgreSQL authority regressed"); + } + Ok(PreparationDisposition::Begin) + } + ProtectedObjectAuthorityState::Importing => { + if sentinel.is_some_and(|sentinel| sentinel.generation != authority.generation) { + anyhow::bail!("Git resumed import generation conflicts with its sentinel"); + } + Ok(PreparationDisposition::Resume) + } + ProtectedObjectAuthorityState::PostgreSql => { + let sentinel = sentinel.ok_or_else(|| { + anyhow::anyhow!("Git PostgreSQL authority is missing its sentinel") + })?; + validate_authority_snapshot(authority, sentinel)?; + Ok(PreparationDisposition::Verify) + } + } +} + +fn sentinel_key(community_id: buzz_core::CommunityId) -> String { + format!("_authority/{community_id}/git-v1.json") +} + +async fn read_sentinel( + state: &AppState, + community_id: buzz_core::CommunityId, +) -> anyhow::Result> { + let key = sentinel_key(community_id); + let Some((_etag, bytes)) = state.git_store.get_pointer(&key).await? else { + return Ok(None); + }; + let sentinel: CutoverSentinel = serde_json::from_slice(&bytes)?; + if sentinel.format_version != SENTINEL_FORMAT_VERSION + || sentinel.community_id != *community_id.as_uuid() + || sentinel.surface != "git" + { + anyhow::bail!("Git cutover sentinel does not match its domain and surface"); + } + validate_digest(&sentinel.inventory_sha256)?; + Ok(Some(sentinel)) +} + +async fn create_sentinel(state: &AppState, sentinel: &CutoverSentinel) -> anyhow::Result<()> { + let key = sentinel_key(buzz_core::CommunityId::from_uuid(sentinel.community_id)); + let body = serde_json::to_vec(sentinel)?; + match state + .git_store + .put_pointer( + &key, + &body, + crate::api::git::store::Precond::IfNoneMatchStar, + ) + .await? + { + crate::api::git::store::CasOutcome::Won(_) => Ok(()), + crate::api::git::store::CasOutcome::LostRace => { + let existing = read_sentinel( + state, + buzz_core::CommunityId::from_uuid(sentinel.community_id), + ) + .await?; + if existing.as_ref() == Some(sentinel) { + Ok(()) + } else { + anyhow::bail!("Git cutover sentinel conflicts with the prepared inventory") + } + } + } +} + +/// Prepare one domain's exact, resumable Git visibility import before serving. +pub async fn prepare_postgres_authority( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + if state.restore_protection().is_some() { + anyhow::bail!( + "Git cutover must complete before the protected restore anchor is provisioned" + ); + } + let authority = state + .db + .protected_object_authority(tenant.community(), ProtectedObjectSurface::Git) + .await?; + let existing_sentinel = read_sentinel(state, tenant.community()).await?; + let disposition = preparation_disposition(&authority, existing_sentinel.as_ref())?; + let authority = if disposition == PreparationDisposition::Begin { + state + .db + .begin_protected_object_import(tenant.community(), ProtectedObjectSurface::Git) + .await? + } else { + authority + }; + if disposition == PreparationDisposition::Verify { + let sentinel = existing_sentinel.ok_or_else(|| { + anyhow::anyhow!("Git verified authority is missing its cutover sentinel") + })?; + return validate_authority_snapshot(&authority, &sentinel); + } + + let reservations = { + let mut transaction = state.db.begin_transaction().await?; + let reservations = buzz_db::protected_publication::list_git_repo_reservations( + &mut transaction, + tenant.community(), + ) + .await?; + transaction.commit().await?; + reservations + }; + + let reservation_map = reservations + .iter() + .map(|(repo, owner, origin)| (repo.clone(), (owner.clone(), origin.clone()))) + .collect::>(); + let mut legacy = BTreeMap::new(); + for (repo_id, owner, origin) in reservations { + let pointer = pointer_key(tenant.community(), &owner, &repo_id); + let Some((_etag, body)) = state.git_store.get_pointer(&pointer).await? else { + if origin == "protected_unpublished" { + // A transaction-owned Enforce announcement intentionally has + // no legacy pointer and may take its first push after cutover. + continue; + } + anyhow::bail!("Git migration found a legacy reservation without a pointer"); + }; + if origin != "legacy" { + anyhow::bail!("Git migration found a protected-unpublished reservation with a pointer"); + } + let digest = std::str::from_utf8(&body) + .map_err(|_| anyhow::anyhow!("Git migration pointer is not UTF-8"))? + .trim() + .to_owned(); + validate_digest(&digest)?; + let _manifest = load_manifest_by_digest(&state.git_store, &digest).await?; + // Fully materialize every referenced pack and ref. A digest-valid + // manifest with a missing or corrupt child must never pass cutover. + let hydrated = hydrate_for_published_read( + &state.git_store, + &digest, + crate::api::git::hydrate::HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }, + ) + .await?; + drop(hydrated); + + let mut transaction = state.db.begin_transaction().await?; + buzz_db::protected_publication::import_git_publication( + &mut transaction, + tenant.community(), + &repo_id, + &owner, + &digest, + ) + .await?; + transaction.commit().await?; + legacy.insert(repo_id, (owner, digest)); + } + + // Re-read every pointer after import. The transaction-held legacy writer + // fence guarantees this set cannot change after `importing` began; this + // second pass detects incompatible writers and object corruption loudly. + for (repo_id, (owner, expected)) in &legacy { + let pointer = pointer_key(tenant.community(), owner, repo_id); + let Some((_etag, body)) = state.git_store.get_pointer(&pointer).await? else { + anyhow::bail!("Git migration pointer disappeared during verification"); + }; + let actual = std::str::from_utf8(&body) + .map_err(|_| anyhow::anyhow!("Git migration pointer is not UTF-8"))? + .trim(); + if actual != expected { + anyhow::bail!("Git migration pointer changed during verification"); + } + } + let verified_reservations = { + let mut transaction = state.db.begin_transaction().await?; + let rows = buzz_db::protected_publication::list_git_repo_reservations( + &mut transaction, + tenant.community(), + ) + .await?; + transaction.commit().await?; + rows.into_iter() + .map(|(repo, owner, origin)| (repo, (owner, origin))) + .collect::>() + }; + if verified_reservations != reservation_map { + anyhow::bail!("Git migration reservation inventory changed during verification"); + } + let postgres = { + let mut transaction = state.db.begin_transaction().await?; + let rows = buzz_db::protected_publication::list_git_publications( + &mut transaction, + tenant.community(), + ) + .await?; + transaction.commit().await?; + rows.into_iter() + .map(|(repo, owner, digest)| (repo, (owner, digest))) + .collect::>() + }; + if postgres != legacy { + anyhow::bail!("Git migration inventory parity failed"); + } + let inventory = git_inventory_digest(&postgres); + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: *tenant.community().as_uuid(), + surface: "git".into(), + generation: authority.generation, + imported_objects: postgres.len() as u64, + inventory_sha256: inventory.clone(), + }; + if let Some(existing) = existing_sentinel { + if existing != sentinel { + anyhow::bail!("Git cutover sentinel does not match the resumed import"); + } + } else { + create_sentinel(state, &sentinel).await?; + } + state + .db + .finalize_protected_object_import( + tenant.community(), + ProtectedObjectSurface::Git, + authority.generation, + postgres.len() as u64, + &inventory, + ) + .await?; + Ok(()) +} + +/// Require a completed, reconciled authority without advancing migration state. +pub async fn require_reconciled_authority( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + let authority = state + .db + .protected_object_authority(tenant.community(), ProtectedObjectSurface::Git) + .await?; + if authority.state != ProtectedObjectAuthorityState::PostgreSql { + anyhow::bail!("Git PostgreSQL authority has not completed preparation"); + } + let sentinel = read_sentinel(state, tenant.community()) + .await? + .ok_or_else(|| anyhow::anyhow!("Git PostgreSQL authority sentinel is missing"))?; + validate_authority_snapshot(&authority, &sentinel) +} + +/// Refuse a legacy lane after the immutable cutover sentinel exists. This is +/// checked in every mode so a restored pre-cutover database cannot revive +/// stale object-store visibility. +pub async fn require_legacy_sentinel_absent( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + if read_sentinel(state, tenant.community()).await?.is_some() { + anyhow::bail!("Git legacy authority is permanently unavailable after cutover"); + } + Ok(()) +} + +fn validate_authority_snapshot( + authority: &buzz_db::protected_visibility::ProtectedObjectAuthority, + sentinel: &CutoverSentinel, +) -> anyhow::Result<()> { + if authority.state != ProtectedObjectAuthorityState::PostgreSql + || authority.generation != sentinel.generation + || authority.imported_objects != Some(sentinel.imported_objects) + || authority.inventory_sha256.as_deref() != Some(&sentinel.inventory_sha256) + { + anyhow::bail!("Git PostgreSQL authority and cutover sentinel disagree"); + } + Ok(()) +} + +fn validate_digest(value: &str) -> anyhow::Result<()> { + if value.len() != 64 + || !value + .chars() + .all(|character| matches!(character, '0'..='9' | 'a'..='f')) + { + anyhow::bail!("Git migration pointer digest is invalid"); + } + Ok(()) +} + +fn git_inventory_digest(inventory: &BTreeMap) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-git-inventory-v1\0"); + for (repo, (owner, manifest)) in inventory { + digest.update(repo.as_bytes()); + digest.update([0]); + digest.update(owner.as_bytes()); + digest.update([0]); + digest.update(manifest.as_bytes()); + digest.update([0]); + } + hex::encode(digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inventory_digest_is_order_independent_and_value_sensitive() { + let mut first = BTreeMap::new(); + first.insert("b".into(), ("owner-b".into(), "b".repeat(64))); + first.insert("a".into(), ("owner-a".into(), "a".repeat(64))); + let mut second = BTreeMap::new(); + second.insert("a".into(), ("owner-a".into(), "a".repeat(64))); + second.insert("b".into(), ("owner-b".into(), "b".repeat(64))); + assert_eq!(git_inventory_digest(&first), git_inventory_digest(&second)); + second.get_mut("b").expect("row").1 = "c".repeat(64); + assert_ne!(git_inventory_digest(&first), git_inventory_digest(&second)); + } + + #[test] + fn authority_snapshot_rejects_restore_regression() { + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: uuid::Uuid::nil(), + surface: "git".into(), + generation: 2, + imported_objects: 1, + inventory_sha256: "a".repeat(64), + }; + let authority = buzz_db::protected_visibility::ProtectedObjectAuthority { + generation: 1, + state: ProtectedObjectAuthorityState::Legacy, + imported_objects: None, + inventory_sha256: None, + }; + assert!(validate_authority_snapshot(&authority, &sentinel).is_err()); + } + + #[test] + fn migration_state_matrix_is_resumable_and_fail_closed() { + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: uuid::Uuid::nil(), + surface: "git".into(), + generation: 2, + imported_objects: 1, + inventory_sha256: "a".repeat(64), + }; + let authority = |state, generation, imported_objects, inventory_sha256| { + buzz_db::protected_visibility::ProtectedObjectAuthority { + generation, + state, + imported_objects, + inventory_sha256, + } + }; + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Legacy, 1, None, None), + None, + ) + .unwrap(), + PreparationDisposition::Begin + ); + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 2, None, None), + None, + ) + .unwrap(), + PreparationDisposition::Resume + ); + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 2, None, None), + Some(&sentinel), + ) + .unwrap(), + PreparationDisposition::Resume + ); + assert_eq!( + preparation_disposition( + &authority( + ProtectedObjectAuthorityState::PostgreSql, + 2, + Some(1), + Some("a".repeat(64)), + ), + Some(&sentinel), + ) + .unwrap(), + PreparationDisposition::Verify + ); + assert!(preparation_disposition( + &authority(ProtectedObjectAuthorityState::Legacy, 1, None, None), + Some(&sentinel), + ) + .is_err()); + assert!(preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 3, None, None), + Some(&sentinel), + ) + .is_err()); + assert!(preparation_disposition( + &authority( + ProtectedObjectAuthorityState::PostgreSql, + 2, + Some(1), + Some("a".repeat(64)), + ), + None, + ) + .is_err()); + } +} diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index dd69d7dc36..d69922f342 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -28,6 +28,7 @@ pub mod hook; pub mod hydrate; pub mod manifest; pub mod manifest_event; +pub mod migration; pub mod pack_cache; pub mod policy; pub mod store; diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 32d63f4600..aadc7b6dcf 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -46,6 +46,7 @@ use buzz_core::git_perms::{ evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind, GIT_NO_CHANNEL_BINDING_BODY, }; +use buzz_db::protected_publication::{GitPolicyCommitFence, GitPolicyGrant}; use buzz_db::EventQuery; use crate::state::AppState; @@ -88,17 +89,20 @@ pub struct HookRefUpdate { } /// Response to the hook — either allow or deny. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct HookCallbackResponse { /// Whether the push is allowed. pub allowed: bool, /// Denial reasons (empty if allowed). - #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub denials: Vec, + /// Exact database rows that must still match at the publication commit. + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_fence: Option, } /// A single denial reason in the hook response. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct DenialResponse { /// The ref that was denied. pub ref_name: String, @@ -364,8 +368,10 @@ pub async fn hook_policy_check( } } }; - let role = if is_repo_owner || is_managed_agent_owner { - MemberRole::Owner + let (role, grant) = if is_repo_owner { + (MemberRole::Owner, GitPolicyGrant::RepoOwner) + } else if is_managed_agent_owner { + (MemberRole::Owner, GitPolicyGrant::ManagedAgentOwner) } else { match channel_id { None => { @@ -382,7 +388,7 @@ pub async fn hook_policy_check( .await { Ok(Some(role_str)) => match role_str.parse::() { - Ok(role) => role, + Ok(role) => (role, GitPolicyGrant::ChannelMember { role: role_str }), Err(_) => { error!(role = %role_str, "hook callback: unknown role"); return (StatusCode::FORBIDDEN, "internal error").into_response(); @@ -424,12 +430,18 @@ pub async fn hook_policy_check( Ok(()) => Json(HookCallbackResponse { allowed: true, denials: vec![], + policy_fence: Some(GitPolicyCommitFence { + announcement_id: repo_event.event.id.to_hex(), + channel_id, + grant, + }), }) .into_response(), Err(denials) => { let response = HookCallbackResponse { allowed: false, denials: denials.into_iter().map(DenialResponse::from).collect(), + policy_fence: None, }; (StatusCode::FORBIDDEN, Json(response)).into_response() } @@ -983,5 +995,11 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu StatusCode::OK, "owner push to a never-bound repo must remain allowed (got body: {body})" ); + let allowed: HookCallbackResponse = serde_json::from_str(&body).expect("policy response"); + assert!(allowed.allowed); + assert!(matches!( + allowed.policy_fence.expect("commit fence").grant, + GitPolicyGrant::RepoOwner + )); } } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d525cd33f5..4303bbea87 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -13,6 +13,7 @@ use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; +use async_trait::async_trait; use axum::{ body::Body, extract::{Path as AxumPath, Query, State}, @@ -23,21 +24,25 @@ use axum::{ }; use base64::Engine; use hex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::process::Command; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; use super::binding::{resolve_repo_binding, RepoBinding}; -use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits}; +use super::cas_publish::{cas_publish, prepare_publish, CasError, ParentState, PublishLimits}; use super::hook::install_hook; use super::hydrate::{ - hydrate_for_read, hydrate_for_write, load_manifest_for_read, HydrateError, HydratedRepo, - HydrationOptions, + hydrate_for_published_read, hydrate_for_published_write, hydrate_for_read, hydrate_for_write, + load_manifest_by_digest, load_manifest_for_read, HydrateError, HydratedRepo, HydrationOptions, }; use super::manifest_event::{build_ref_state_event, RefStateInputs}; +use crate::authorization_runtime::transport::{authorize_if_configured, ProtectedAuthorization}; use crate::state::AppState; +use buzz_auth::{AuthTransport, AuthorizationCapability}; use buzz_core::TenantContext; +use buzz_db::protected_publication::{ExpectedGitPublication, GitPublicationOutcome}; /// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`). const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); @@ -76,7 +81,9 @@ pub struct GitAuth { pub tenant: TenantContext, /// Cryptographically verified identity staged until repository policy /// authorization succeeds. - identity_proof: crate::corporate_identity::CorporateIdentityProof, + identity_proof: Option, + /// Sealed NIP-98 evidence retained for every request checkpoint. + verified_proof: Arc, } impl axum::extract::FromRequestParts> for GitAuth { @@ -88,6 +95,19 @@ impl axum::extract::FromRequestParts> for GitAuth { ) -> Result { let method = parts.method.as_str(); + // Row zero for Git HTTP: bind the request Host to a server-resolved + // tenant before even disclosing that this is an authenticated route. + // This keeps an unmapped host indistinguishable from a missing repo and + // matches every other protected transport's pre-auth host boundary. + let raw_host = parts + .headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?; + let auth_header = parts .headers .get(header::AUTHORIZATION) @@ -121,19 +141,10 @@ impl axum::extract::FromRequestParts> for GitAuth { let event_json = String::from_utf8(event_bytes) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; - // Row zero for Git HTTP: bind the request Host to a server-resolved - // tenant before URL verification. We still do not trust forwarded - // headers; the signed `u` tag is checked against the host that resolved - // through the authoritative communities table, not a deployment-global - // `config.relay_url` and not any client-supplied community value. - let raw_host = parts - .headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?; + // We still do not trust forwarded headers: the signed `u` tag is + // checked against the host resolved through the authoritative + // communities table, not a deployment-global `config.relay_url` or a + // client-supplied community value. let expected_url = git_expected_url( &state.config.relay_url, &tenant, @@ -187,12 +198,21 @@ impl axum::extract::FromRequestParts> for GitAuth { // body=None: can't buffer streaming pack data to verify payload hash. // Token is time-bounded (±60s) and URL-locked — acceptable trade-off. - let pubkey = - buzz_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None) - .map_err(|e| { - warn!(error = %e, "git NIP-98 auth failed"); + let verified_proof = buzz_auth::VerifiedEvidenceAdapter::new() + .verify_nip98( + tenant.community(), + AuthTransport::Git, + &event_json, + &expected_url, + &event_method, + None, + None, + ) + .map_err(|error| { + warn!(error = %error, "git NIP-98 auth failed"); (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() })?; + let pubkey = verified_proof.actor_pubkey(); // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. // Git's credential protocol reuses one signed token across multiple requests @@ -214,22 +234,34 @@ impl axum::extract::FromRequestParts> for GitAuth { .get("x-auth-tag") .and_then(|value| value.to_str().ok()); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( + state, + tenant.community(), &parts.headers, - &state.config.corporate_identity, - ); + ) + .map_err(|error| (error.status_code(), error.public_message()).into_response())?; let identity_proof = match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await { - Ok(proof) => proof, + Ok(proof) => Some(proof), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + warn!(error = ?error, "observational git identity verification unavailable"); + None + } Err(e) => { - warn!(pubkey = %pubkey.to_hex(), error = %e, "git: corporate identity denied"); + warn!(error = ?e, "git: corporate identity denied"); return Err((e.status_code(), e.public_message()).into_response()); } }; @@ -242,32 +274,202 @@ impl axum::extract::FromRequestParts> for GitAuth { .await .is_err() { - warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); + warn!("git: relay membership denied"); return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } + let verified_proof = + match crate::corporate_identity::verify_unconditional_nip_oa_owner(pubkey, auth_tag) { + Some(owner) => buzz_auth::VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_proof, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + owner, pubkey, None, true, + ), + ) + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + "NIP-98 delegation evidence mismatch", + ) + .into_response() + })?, + None => verified_proof, + }; Ok(GitAuth { pubkey, tenant, identity_proof, + verified_proof: Arc::new(verified_proof), }) } } async fn finalize_git_corporate_identity(state: &AppState, auth: &GitAuth) -> Result<(), Response> { + if crate::authorization_runtime::transport::legacy_identity_lane(state, auth.tenant.community()) + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(()); + } + let Some(proof) = auth.identity_proof.clone() else { + return Ok(()); + }; crate::corporate_identity::finalize_corporate_identity( state, auth.tenant.community(), auth.pubkey, - auth.identity_proof.clone(), + proof, ) .await .map(|_| ()) .map_err(|e| { - warn!(pubkey = %auth.pubkey.to_hex(), error = %e, "git: corporate identity finalization denied"); + warn!(error = ?e, "git: corporate identity finalization denied"); (e.status_code(), e.public_message()).into_response() }) } +fn protected_git_denied(error: impl std::fmt::Display) -> Response { + warn!(error = %error, "git: protected authorization denied"); + (StatusCode::FORBIDDEN, "protected authorization denied").into_response() +} + +#[derive(Clone)] +enum GitPublicationLane { + Legacy, + PostgreSql(Option), +} + +async fn git_publication_lane( + state: &AppState, + tenant: &TenantContext, + owner: &str, + repo_id: &str, + authority: &ProtectedAuthorization, +) -> Result { + let mut visibility = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(protected_git_denied)?; + if authority.is_enforcing() + && visibility.state + != buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql + { + crate::api::git::migration::require_reconciled_authority(state, tenant) + .await + .map_err(protected_git_denied)?; + visibility = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(protected_git_denied)?; + } + if visibility.state == buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql + { + // Cutover is monotonic, but the visibility source is independent of + // the protected-authorization mode. Off, Shadow, and VerifyOnly retain + // their legacy authorization decision while reading the same + // PostgreSQL publication selected in Enforce. No mode may fall back to + // the mutable legacy pointer after the sentinel is installed. + let publication = state + .db + .git_publication(tenant.community(), repo_id, owner) + .await + .map_err(protected_git_denied)?; + authority.revalidate().map_err(protected_git_denied)?; + return Ok(GitPublicationLane::PostgreSql(publication.map( + |publication| ExpectedGitPublication { + publication_version: publication.publication_version, + manifest_sha256: publication.manifest_sha256, + }, + ))); + } + if authority.is_enforcing() { + return Err(protected_git_denied( + "protected Git visibility authority is unavailable", + )); + } + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant) + .await + .map_err(protected_git_denied)?; + Ok(GitPublicationLane::Legacy) +} + +enum GitPublicationSource<'a> { + Legacy, + Published(&'a str), + Unpublished, +} + +fn publication_source(lane: &GitPublicationLane) -> GitPublicationSource<'_> { + match lane { + GitPublicationLane::Legacy => GitPublicationSource::Legacy, + GitPublicationLane::PostgreSql(Some(publication)) => { + GitPublicationSource::Published(publication.manifest_sha256.as_str()) + } + GitPublicationLane::PostgreSql(None) => GitPublicationSource::Unpublished, + } +} + +async fn authorize_git_operation( + state: &AppState, + auth: &GitAuth, + capability: AuthorizationCapability, + surface: &'static str, +) -> Result, Response> { + let verified_assertion = match auth.identity_proof.as_ref() { + Some(proof) => match crate::corporate_identity::current_verified_assertion_for_proof( + state, + proof, + auth.tenant.community(), + AuthTransport::Git, + ) { + Ok(assertion) => assertion.map(Arc::new), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + auth.tenant.community(), + ) + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + warn!(error = %error, "observational git assertion sealing unavailable"); + None + } + Err(error) => return Err(protected_git_denied(error)), + }, + None => None, + }; + let fingerprint = auth.verified_proof.operation_binding().fingerprint(); + let mut correlation = [0_u8; 16]; + correlation.copy_from_slice(&fingerprint[..16]); + correlation[6] = (correlation[6] & 0x0f) | 0x50; + correlation[8] = (correlation[8] & 0x3f) | 0x80; + let authority = Arc::new( + authorize_if_configured( + state, + Arc::clone(&auth.verified_proof), + verified_assertion, + capability, + uuid::Uuid::from_bytes(correlation), + surface, + ) + .await + .map_err(protected_git_denied)?, + ); + authority.revalidate().map_err(protected_git_denied)?; + Ok(authority) +} + +#[allow(clippy::result_large_err)] +fn revalidate_git_authority(authority: &ProtectedAuthorization) -> Result<(), Response> { + authority.revalidate().map_err(protected_git_denied) +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -381,8 +583,8 @@ fn acquire_git_permit( /// Convert a [`HydrateError`] to the HTTP response shape the read+write /// paths share. Below-pointer failure ⇒ 5xx; pointer-absent is signalled /// via `Ok(None)` from [`hydrate_for_read`] and never reaches this fn. -fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Response { - error!(error = %err, owner = %owner, repo = %repo, "hydrate failed"); +fn hydrate_error_to_response(_owner: &str, _repo: &str, err: HydrateError) -> Response { + error!(error = %err, "hydrate failed"); if matches!(err, HydrateError::ResourceLimit(_)) { return ( StatusCode::PAYLOAD_TOO_LARGE, @@ -453,7 +655,21 @@ async fn authorize_git_read( limit: Some(1), ..buzz_db::EventQuery::for_community(community) }; - let repo_event = match db.query_events(&query).await { + let mut transaction = match db.begin_transaction().await { + Ok(transaction) => transaction, + Err(error) => { + error!(repo = %repo_name, %error, "git read gate: snapshot start failed (deny)"); + return Err(denied()); + } + }; + if let Err(error) = sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *transaction) + .await + { + error!(repo = %repo_name, %error, "git read gate: snapshot selection failed (deny)"); + return Err(denied()); + } + let repo_event = match buzz_db::event::query_events_tx(&mut transaction, &query).await { Ok(mut events) => match events.pop() { Some(event) => event, None => return Err(denied()), @@ -492,9 +708,13 @@ async fn authorize_git_read( } }; - match db - .get_member_role(community, channel_id, &caller.to_bytes()) - .await + match buzz_db::channel::get_member_role_tx( + &mut transaction, + community, + channel_id, + &caller.to_bytes(), + ) + .await { Ok(role) if read_role_allows(role.as_deref()) => Ok(()), Ok(_) => Err(denied()), @@ -505,6 +725,60 @@ async fn authorize_git_read( } } +#[async_trait] +trait GitReadReleaseAuthority: Send + Sync { + async fn release(&self) -> bool; +} + +struct GitReadReleaseFence { + db: buzz_db::Db, + community: buzz_core::CommunityId, + caller: nostr::PublicKey, + owner: String, + repo: String, + protected: Arc, +} + +#[async_trait] +impl GitReadReleaseAuthority for GitReadReleaseFence { + async fn release(&self) -> bool { + if self.protected.revalidate().is_err() { + return false; + } + if authorize_git_read( + &self.db, + self.community, + &self.caller, + &self.owner, + &self.repo, + ) + .await + .is_err() + { + return false; + } + self.protected.revalidate().is_ok() + } +} + +fn git_read_release_fence( + state: &AppState, + tenant: &TenantContext, + caller: &nostr::PublicKey, + owner: &str, + repo: &str, + protected: Arc, +) -> Arc { + Arc::new(GitReadReleaseFence { + db: state.db.clone(), + community: tenant.community(), + caller: *caller, + owner: owner.to_owned(), + repo: repo.to_owned(), + protected, + }) +} + /// Pure decision for [`authorize_git_read`]: a read requires a current /// active membership row whose role the relay recognizes. /// @@ -724,8 +998,20 @@ pub async fn info_refs( repo_name, ) .await?; + let capability = crate::protected_surface::git_info_refs_capability(service) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "invalid service").into_response())?; + let protected_authority = + authorize_git_operation(&state, &auth, capability, "git.info_refs").await?; + revalidate_git_authority(&protected_authority)?; finalize_git_corporate_identity(&state, &auth).await?; - + let publication_lane = git_publication_lane( + &state, + &auth.tenant, + ¶ms.owner, + repo_name, + &protected_authority, + ) + .await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, // delete-refs, atomic, …) that we don't reproduce, so it always takes @@ -733,9 +1019,33 @@ pub async fn info_refs( if service == "git-upload-pack" { // Load just the verified manifest — no object materialization, no // permit. `Ok(None)` = pointer absent = repo never existed → 404. - match load_manifest_for_read(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo) - .await - { + let manifest = match publication_source(&publication_lane) { + GitPublicationSource::Published(digest) => { + load_manifest_by_digest(&state.git_store, digest) + .await + .map(Some) + } + GitPublicationSource::Legacy => { + load_manifest_for_read(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo) + .await + } + GitPublicationSource::Unpublished => Ok(None), + }; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let read_fence = git_read_release_fence( + &state, + &auth.tenant, + &auth.pubkey, + ¶ms.owner, + repo_name, + Arc::clone(&protected_authority), + ); + if !read_fence.release().await { + return Err((StatusCode::NOT_FOUND, "repository not found").into_response()); + } + match manifest { Ok(Some(manifest)) if fast_path_eligible(&manifest) => { let body = build_upload_pack_advertisement(&manifest); return Ok(Response::builder() @@ -745,7 +1055,7 @@ pub async fn info_refs( "application/x-git-upload-pack-advertisement", ) .header(header::CACHE_CONTROL, "no-cache") - .body(Body::from(body)) + .body(guard_git_buffered_body(body, read_fence)) .unwrap()); } // Eligible repo but has tags, or below-pointer failure handling: @@ -760,7 +1070,16 @@ pub async fn info_refs( // Subprocess path: receive-pack advertisement, or upload-pack for a // tagged repo. Acquires a permit and hydrates — today's behavior. - info_refs_subprocess(&state, &auth.tenant, service, ¶ms).await + info_refs_subprocess( + &state, + &auth.tenant, + service, + ¶ms, + &auth.pubkey, + &protected_authority, + &publication_lane, + ) + .await } /// Subprocess-backed `info/refs` advertisement: hydrate the published state @@ -775,23 +1094,46 @@ async fn info_refs_subprocess( tenant: &TenantContext, service: &str, params: &GitRepoParams, + caller: &nostr::PublicKey, + protected_authority: &Arc, + publication_lane: &GitPublicationLane, ) -> Result { + revalidate_git_authority(protected_authority)?; let _permit = acquire_git_permit(state, "info_refs")?; - let repo = match hydrate_for_read( - &state.git_store, - tenant, - ¶ms.owner, - ¶ms.repo, - HydrationOptions { - pack_cache: &state.git_pack_cache, - scratch_dir: &state.config.git_repo_path, - max_pack_bytes: state.config.git_max_pack_bytes, - max_repo_bytes: state.config.git_max_repo_bytes, - }, - ) - .await - { + let options = HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }; + let hydrated = match publication_source(publication_lane) { + GitPublicationSource::Published(digest) => { + hydrate_for_published_read(&state.git_store, digest, options) + .await + .map(Some) + } + GitPublicationSource::Legacy => { + hydrate_for_read( + &state.git_store, + tenant, + ¶ms.owner, + ¶ms.repo, + options, + ) + .await + } + GitPublicationSource::Unpublished if service == "git-receive-pack" => { + hydrate_for_published_write(&state.git_store, None, options) + .await + .map(|(repo, _parent)| Some(repo)) + } + GitPublicationSource::Unpublished => Ok(None), + }; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let repo = match hydrated { Ok(Some(repo)) => repo, Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()), Err(e) => return Err(hydrate_error_to_response(¶ms.owner, ¶ms.repo, e)), @@ -834,8 +1176,11 @@ async fn info_refs_subprocess( (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; - let status = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait()) - .await + let waited = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait()).await; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let status = waited .map_err(|_| { warn!( "git info_refs subprocess timed out ({}s)", @@ -850,11 +1195,18 @@ async fn info_refs_subprocess( if !status.success() { let stderr = read_log_prefix(stderr_tmp.path(), 64 * 1024).await; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; error!(stderr = %stderr, "git --advertise-refs failed"); return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()); } - let stdout_len = tokio::fs::metadata(stdout_tmp.path()) - .await + + let metadata = tokio::fs::metadata(stdout_tmp.path()).await; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let stdout_len = metadata .map_err(|e| { error!(error = %e, "git info_refs stdout metadata failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() @@ -872,10 +1224,26 @@ async fn info_refs_subprocess( ) .into_response()); } - let stdout = tokio::fs::read(stdout_tmp.path()).await.map_err(|e| { + let stdout_result = tokio::fs::read(stdout_tmp.path()).await; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let stdout = stdout_result.map_err(|e| { error!(error = %e, "git info_refs stdout read failed"); (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() })?; + let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let read_fence = git_read_release_fence( + state, + tenant, + caller, + ¶ms.owner, + repo_name, + Arc::clone(protected_authority), + ); + if !read_fence.release().await { + return Err((StatusCode::NOT_FOUND, "repository not found").into_response()); + } // `repo` (the tempdir) must live until *after* the subprocess has read // its objects. Holding it until here is the structural lifetime that // guarantees that. @@ -894,7 +1262,7 @@ async fn info_refs_subprocess( .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CACHE_CONTROL, "no-cache") - .body(Body::from(body)) + .body(guard_git_buffered_body(body, read_fence)) .unwrap()) } @@ -954,6 +1322,61 @@ fn decode_git_request_body( Body::from_stream(capped) } +fn guard_git_request_body(body: Body, authority: Arc) -> Body { + use futures_util::StreamExt; + + let stream = body.into_data_stream().map(move |item| { + authority.revalidate().map_err(|error| { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, error.to_string()) + })?; + item.map_err(std::io::Error::other) + }); + Body::from_stream(stream) +} + +fn guard_git_buffered_body(bytes: Vec, authority: Arc) -> Body { + let stream = futures_util::stream::once(async move { + if authority.release().await { + Ok(bytes::Bytes::from(bytes)) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Git read authority changed before response release", + )) + } + }); + Body::from_stream(stream) +} + +fn guard_git_read_stream( + stream: S, + authority: Arc, +) -> impl futures_util::Stream> + Send +where + S: futures_util::Stream> + Send + 'static, +{ + futures_util::stream::unfold( + (Box::pin(stream), authority, false), + |(mut stream, authority, finished)| async move { + if finished { + return None; + } + use futures_util::StreamExt; + let item = stream.next().await; + if !authority.release().await { + return Some(( + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Git read authority changed during response streaming", + )), + (stream, authority, true), + )); + } + item.map(|item| (item, (stream, authority, false))) + }, + ) +} + /// `POST /git/{owner}/{repo}/git-upload-pack` /// /// Handles clone/fetch — client sends wants/haves, server sends pack data. @@ -981,29 +1404,70 @@ pub async fn upload_pack( repo_name, ) .await?; + let protected_authority = authorize_git_operation( + &state, + &auth, + AuthorizationCapability::GitRead, + "git.upload_pack", + ) + .await?; + revalidate_git_authority(&protected_authority)?; finalize_git_corporate_identity(&state, &auth).await?; + let publication_lane = git_publication_lane( + &state, + &auth.tenant, + ¶ms.owner, + repo_name, + &protected_authority, + ) + .await?; let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; - let repo = match hydrate_for_read( - &state.git_store, - &auth.tenant, - ¶ms.owner, - ¶ms.repo, - HydrationOptions { - pack_cache: &state.git_pack_cache, - scratch_dir: &state.config.git_repo_path, - max_pack_bytes: state.config.git_max_pack_bytes, - max_repo_bytes: state.config.git_max_repo_bytes, - }, - ) - .await - { + let options = HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }; + let hydrated = match publication_source(&publication_lane) { + GitPublicationSource::Published(digest) => { + hydrate_for_published_read(&state.git_store, digest, options) + .await + .map(Some) + } + GitPublicationSource::Legacy => { + hydrate_for_read( + &state.git_store, + &auth.tenant, + ¶ms.owner, + ¶ms.repo, + options, + ) + .await + } + GitPublicationSource::Unpublished => Ok(None), + }; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + let repo = match hydrated { Ok(Some(repo)) => repo, Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()), Err(e) => return Err(hydrate_error_to_response(¶ms.owner, ¶ms.repo, e)), }; + let read_fence = git_read_release_fence( + &state, + &auth.tenant, + &auth.pubkey, + ¶ms.owner, + repo_name, + Arc::clone(&protected_authority), + ); + if !read_fence.release().await { + return Err((StatusCode::NOT_FOUND, "repository not found").into_response()); + } // Track A: stream the subprocess stdout straight into the response body // instead of buffering the whole pack into RAM. `repo` (the hydrated @@ -1012,6 +1476,8 @@ pub async fn upload_pack( stream_git_read( repo, permit, + protected_authority, + read_fence, "upload-pack", &[], body, @@ -1055,7 +1521,23 @@ pub async fn receive_pack( body: Body, ) -> Result { let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let protected_authority = authorize_git_operation( + &state, + &auth, + AuthorizationCapability::GitWrite, + "git.receive_pack", + ) + .await?; + let publication_lane = git_publication_lane( + &state, + &auth.tenant, + ¶ms.owner, + repo_name, + &protected_authority, + ) + .await?; let body = decode_git_request_body(&headers, body, state.config.git_max_pack_bytes); + let body = guard_git_request_body(body, Arc::clone(&protected_authority)); let pusher_hex = hex::encode(auth.pubkey.to_bytes()); let _permit = acquire_git_permit(&state, "receive_pack")?; @@ -1067,34 +1549,49 @@ pub async fn receive_pack( // and CAS is the only serialization that holds. The named tradeoff: // two concurrent same-repo pushes each hydrate + run receive-pack, // and the loser's CPU/IO is thrown away on `Conflict`. **Accepted - // for v1** — same-ref contention is rare, and a cross-instance lock - // would be a distributed-lock service we explicitly don't want. - // If contention shows up in metrics, the fix is a short local - // best-effort lock as a *latency optimization*, never a correctness - // dependency. (Eva's call, on record in #proj-git-on-s3 with the - // ParentState seam review.) + // for v1** — same-ref contention is rare, and a cross-instance lock is + // deliberately outside this repository's object-store contract. A short + // local lock may later reduce duplicate work, but is never a correctness + // dependency. // Hydrate parent state + workspace in one round-trip. ParentState // travels with the workspace into finalize_push so the CAS predicates // on the same pointer ETag the workspace was hydrated from. - let (repo, parent_state) = hydrate_for_write( - &state.git_store, - &auth.tenant, - ¶ms.owner, - ¶ms.repo, - HydrationOptions { - pack_cache: &state.git_pack_cache, - scratch_dir: &state.config.git_repo_path, - max_pack_bytes: state.config.git_max_pack_bytes, - max_repo_bytes: state.config.git_max_repo_bytes, - }, - ) - .await + revalidate_git_authority(&protected_authority)?; + let options = HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }; + let (repo, parent_state) = match &publication_lane { + GitPublicationLane::Legacy => { + hydrate_for_write( + &state.git_store, + &auth.tenant, + ¶ms.owner, + ¶ms.repo, + options, + ) + .await + } + GitPublicationLane::PostgreSql(publication) => { + hydrate_for_published_write( + &state.git_store, + publication + .as_ref() + .map(|publication| publication.manifest_sha256.as_str()), + options, + ) + .await + } + } .map_err(|e| hydrate_error_to_response(¶ms.owner, ¶ms.repo, e))?; // Install the pre-receive hook into the ephemeral workspace. The // hook script is fixed per-deployment; per-push state (callback URL, // HMAC secret, pusher pubkey) rides in env at exec time. + revalidate_git_authority(&protected_authority)?; install_hook(repo.path()).await.map_err(|e| { error!(error = %e, "install pre-receive hook into hydrated workspace"); (StatusCode::INTERNAL_SERVER_ERROR, "git hook install failed").into_response() @@ -1106,6 +1603,7 @@ pub async fn receive_pack( state.config.bind_addr.port() ); let hooks_dir = repo.path().join("hooks").display().to_string(); + let policy_fence_path = repo.path().join("protected-policy-fence.json"); let mut hook_env = vec![ ("BUZZ_HOOK_URL", hook_url), ( @@ -1119,12 +1617,17 @@ pub async fn receive_pack( auth.tenant.community().as_uuid().to_string(), ), ("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()), + ( + "BUZZ_POLICY_FENCE_PATH", + policy_fence_path.display().to_string(), + ), ]; hook_env.extend(receive_pack_git_config(hooks_dir)); // Run receive-pack against the tempdir. Returns the *owned* subprocess // output (PackOutput) — crucially NOT a Response, so the post-push // fence in finalize_push can sequence the CAS before any 2xx exists. + revalidate_git_authority(&protected_authority)?; let pack = run_git_at( repo.path(), "receive-pack", @@ -1134,6 +1637,24 @@ pub async fn receive_pack( RECEIVE_PACK_MAX_OUTPUT_BYTES, ) .await?; + revalidate_git_authority(&protected_authority)?; + let policy_fence = if pack.ok && matches!(publication_lane, GitPublicationLane::PostgreSql(_)) { + let bytes = tokio::fs::read(&policy_fence_path) + .await + .map_err(protected_git_denied)?; + let response: super::policy::HookCallbackResponse = + serde_json::from_slice(&bytes).map_err(protected_git_denied)?; + if !response.allowed { + return Err(protected_git_denied("Git policy denied publication")); + } + response + .policy_fence + .ok_or_else(|| protected_git_denied("Git policy fence unavailable"))? + .into() + } else { + None + }; + let _ = tokio::fs::remove_file(&policy_fence_path).await; let ctx = PushContext { pack, @@ -1144,6 +1665,9 @@ pub async fn receive_pack( pusher: auth.pubkey, tenant: auth.tenant, identity_proof: auth.identity_proof, + protected_authority, + publication_lane, + policy_fence, repo_handle: repo, }; Ok(finalize_push(&state, ctx).await) @@ -1477,6 +2001,7 @@ struct StreamingGit { /// Pumping the request body is detached from response polling. Abort it /// when the response is dropped or the subprocess times out. stdin_task: tokio::task::JoinHandle<()>, + protected_authority: Arc, } /// Adds a hard deadline and lifecycle metrics to upload-pack stdout. @@ -1514,6 +2039,21 @@ where } } +/// Revalidates every completed stream poll before its outcome is observable. +/// +/// Backend errors and EOF can disclose execution state just as a successful +/// chunk can disclose bytes, so all three `Ready` shapes cross the same final +/// authority boundary. `Pending` emits nothing and is left untouched. +fn release_ready_git_poll( + poll: std::task::Poll>>, + release: impl FnOnce() -> Result<(), R>, +) -> Result>>, R> { + if matches!(poll, std::task::Poll::Ready(_)) { + release()?; + } + Ok(poll) +} + impl futures_util::Stream for StreamingGit { type Item = Result; @@ -1522,6 +2062,21 @@ impl futures_util::Stream for StreamingGit { cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { let poll = std::pin::Pin::new(&mut self.inner).poll_next(cx); + let poll = match release_ready_git_poll(poll, || { + self.protected_authority.release_fetched(()) + }) { + Ok(poll) => poll, + Err(error) => { + self.stdin_task.abort(); + if let Err(kill_error) = self.child.start_kill() { + warn!(error = %kill_error, "unauthorized git upload-pack could not be killed"); + } + return std::task::Poll::Ready(Some(Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + error.to_string(), + )))); + } + }; if matches!( &poll, std::task::Poll::Ready(Some(Err(error))) @@ -1615,10 +2170,12 @@ impl Drop for StreamingGit { /// stream, not via HTTP status. The buffered [`run_git_at`] stays the push /// path's runner precisely because the fence needs the bytes in hand before /// committing to a status. -#[allow(clippy::result_large_err)] +#[allow(clippy::result_large_err, clippy::too_many_arguments)] fn stream_git_read( repo: HydratedRepo, permit: tokio::sync::OwnedSemaphorePermit, + protected_authority: Arc, + read_authority: Arc, service: &'static str, extra_args: &[&str], body: Body, @@ -1645,10 +2202,14 @@ fn stream_git_read( // Pump the request body into git's stdin, then close it (EOF). Detached: // the task ends on its own when the body ends or the write fails. let mut stdin = child.stdin.take().expect("stdin piped"); + let input_authority = Arc::clone(&read_authority); let stdin_task = tokio::spawn(async move { use futures_util::StreamExt; let mut stream = body.into_data_stream(); while let Some(chunk) = stream.next().await { + if !input_authority.release().await { + break; + } match chunk { Ok(bytes) => { if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes) @@ -1677,19 +2238,25 @@ fn stream_git_read( child, _repo: repo, stdin_task, + protected_authority: Arc::clone(&protected_authority), }; // Prepend any protocol header (info/refs) ahead of git's stdout. The // prefix is a single ready chunk; the rest streams from the subprocess. - let prefix_stream = - futures_util::stream::once( - async move { Ok::<_, std::io::Error>(bytes::Bytes::from(prefix)) }, - ); + let prefix_stream = futures_util::stream::once(async move { Ok(bytes::Bytes::from(prefix)) }); + let guarded_stream = guard_git_read_stream( + futures_util::StreamExt::chain(prefix_stream, git_stream), + read_authority, + ); let body_stream = GitPermitStream { - inner: Box::pin(futures_util::StreamExt::chain(prefix_stream, git_stream)), + inner: Box::pin(guarded_stream), _permit: permit, }; + protected_authority + .release_fetched(()) + .map_err(protected_git_denied)?; + Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) @@ -1736,13 +2303,25 @@ pub(crate) struct PushContext { /// any derived kind:30618 event from this push. pub tenant: TenantContext, /// Identity proof finalized only after the pre-receive policy hook accepts. - pub identity_proof: crate::corporate_identity::CorporateIdentityProof, + pub identity_proof: Option, + /// Retained GitWrite authority rechecked before identity mutation and CAS. + pub protected_authority: Arc, + /// Visibility commit primitive selected by the exact-domain mode. + publication_lane: GitPublicationLane, + /// Exact database policy decision returned by the pre-receive hook. + policy_fence: Option, /// The hydrated workspace handle. Held until response construction /// (which happens *after* `cas_publish` returns) so the tempdir /// outlives the receive-pack subprocess and the CAS publish. pub repo_handle: HydratedRepo, } +#[derive(Debug, Serialize, Deserialize)] +struct GitPushReceipt { + manifest_sha256: String, + publication_version: u64, +} + /// Finalize a push request: CAS-commit the new state into the object /// store, derive kind:30618 from the committed manifest, and only then /// build the success response. @@ -1773,8 +2352,6 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { // hook's decline message; only the publish side effects are suppressed. if !ctx.pack.ok { warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, "receive-pack exited non-zero (e.g. pre-receive hook decline); \ skipping CAS publish and kind:30618 — no state published" ); @@ -1783,47 +2360,90 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { return response; } - if let Err(error) = crate::corporate_identity::finalize_corporate_identity( - state, - ctx.tenant.community(), - ctx.pusher, - ctx.identity_proof.clone(), - ) - .await + if let Err(response) = revalidate_git_authority(&ctx.protected_authority) { + return response; + } + if crate::authorization_runtime::transport::legacy_identity_lane(state, ctx.tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy { - warn!(pusher = %ctx.pusher.to_hex(), error = %error, "git: post-policy corporate identity finalization denied"); - return (error.status_code(), error.public_message()).into_response(); + if let Some(identity_proof) = ctx.identity_proof.clone() { + if let Err(error) = crate::corporate_identity::finalize_corporate_identity( + state, + ctx.tenant.community(), + ctx.pusher, + identity_proof, + ) + .await + { + warn!(error = ?error, "git: post-policy corporate identity finalization denied"); + return (error.status_code(), error.public_message()).into_response(); + } + } } // Step 7 (CAS). The PushContext binds `parent_state` (observed at // hydrate) to the CAS predicate here — no re-reading of the pointer // between hydrate and CAS. - let success = match cas_publish( - &state.git_store, - &ctx.tenant, - ctx.repo_handle.path(), - &ctx.owner, - &ctx.repo, - &ctx.parent_state, - PublishLimits { - parent_hydrated_bytes: ctx.repo_handle.hydrated_bytes(), - max_pack_bytes: state.config.git_max_pack_bytes, - max_repo_bytes: state.config.git_max_repo_bytes, - }, - ) - .await - { + if let Err(response) = revalidate_git_authority(&ctx.protected_authority) { + return response; + } + let limits = PublishLimits { + parent_hydrated_bytes: ctx.repo_handle.hydrated_bytes(), + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }; + let publication = match &ctx.publication_lane { + GitPublicationLane::Legacy => { + let legacy_visibility = match state + .db + .begin_legacy_visibility_write( + ctx.tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + { + Ok(guard) => guard, + Err(error) => return protected_git_denied(error), + }; + if let Err(error) = + crate::api::git::migration::require_legacy_sentinel_absent(state, &ctx.tenant).await + { + return protected_git_denied(error); + } + let publication = cas_publish( + &state.git_store, + &ctx.tenant, + ctx.repo_handle.path(), + &ctx.owner, + &ctx.repo, + &ctx.parent_state, + limits, + ) + .await; + if publication.is_ok() { + if let Err(error) = legacy_visibility.commit().await { + return protected_git_denied(error); + } + } + publication + } + GitPublicationLane::PostgreSql(_) => { + prepare_publish( + &state.git_store, + &ctx.tenant, + ctx.repo_handle.path(), + &ctx.owner, + &ctx.repo, + &ctx.parent_state, + limits, + ) + .await + } + }; + let success = match publication { Ok(s) => s, - Err(CasError::Conflict { - winner_manifest_key, - .. - }) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - winner = %winner_manifest_key, - "push lost CAS race; tempdir dropped, returning 409" - ); + Err(CasError::Conflict { .. }) => { + warn!("push lost CAS race; tempdir dropped, returning 409"); return ( StatusCode::CONFLICT, "push superseded by a concurrent writer; pull and retry", @@ -1836,8 +2456,6 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { // empty head, malformed parent). Pre-CAS — no pointer was // written. warn!( - owner = %ctx.owner, - repo = %ctx.repo, error = %e, "push rejected: manifest validation failed" ); @@ -1849,8 +2467,6 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { } Err(CasError::ResourceLimit(e)) => { warn!( - owner = %ctx.owner, - repo = %ctx.repo, error = %e, "push rejected: repo exceeds relay resource limits" ); @@ -1867,8 +2483,6 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { // winner-fetch, the winner is already installed and the // loser's data is unrelated). error!( - owner = %ctx.owner, - repo = %ctx.repo, error = %e, "push failed pre-response" ); @@ -1876,6 +2490,149 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { } }; + let mut committed_now = true; + if let GitPublicationLane::PostgreSql(expected) = &ctx.publication_lane { + let Some(manifest_sha256) = success.manifest_key.strip_prefix("manifests/") else { + return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); + }; + let mut operation_key = Sha256::new(); + operation_key.update(b"buzz-git-publish-operation-v2"); + operation_key.update(ctx.tenant.community().as_uuid().as_bytes()); + operation_key.update(ctx.owner.as_bytes()); + operation_key.update(ctx.repo_id.as_bytes()); + operation_key.update(ctx.pusher.to_bytes()); + operation_key.update(manifest_sha256.as_bytes()); + let operation_key: [u8; 32] = operation_key.finalize().into(); + let operation_id = + match crate::authorization_runtime::executor::ProtectedOperationId::derive( + ctx.tenant.community(), + "git.publish.v2", + &operation_key, + ) { + Ok(operation_id) => operation_id, + Err(error) => return protected_git_denied(error), + }; + let request_fingerprint = operation_key; + if ctx.protected_authority.is_enforcing() { + let permit = match ctx.protected_authority.seal_postgres_mutation( + operation_id, + "git.publish.v2", + request_fingerprint, + ) { + Ok(Some(permit)) => permit, + Ok(None) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "git authorization error") + .into_response() + } + Err(error) => return protected_git_denied(error), + }; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + { + Ok(crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + )) => { + let receipt: GitPushReceipt = match serde_json::from_slice(&payload) { + Ok(receipt) => receipt, + Err(error) => return protected_git_denied(error), + }; + if receipt.manifest_sha256 != manifest_sha256 { + return ( + StatusCode::CONFLICT, + "push operation was retried with different content", + ) + .into_response(); + } + committed_now = false; + } + Ok(crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + )) => { + let Some(policy_fence) = ctx.policy_fence.as_ref() else { + return protected_git_denied("Git policy fence unavailable"); + }; + let outcome = buzz_db::protected_publication::compare_and_publish_git( + operation.transaction(), + buzz_db::protected_publication::GitPublicationRequest { + community_id: ctx.tenant.community(), + repo_id: &ctx.repo_id, + owner_pubkey: &ctx.owner, + expected: expected.as_ref(), + manifest_sha256, + pusher_pubkey: &ctx.pusher.to_bytes(), + policy: policy_fence, + }, + ) + .await; + let published = match outcome { + Ok(GitPublicationOutcome::Published(publication)) => publication, + Ok(GitPublicationOutcome::Conflict) => { + return ( + StatusCode::CONFLICT, + "push superseded by a concurrent writer; pull and retry", + ) + .into_response() + } + Err(error) => return protected_git_denied(error), + }; + let receipt = GitPushReceipt { + manifest_sha256: published.manifest_sha256, + publication_version: published.publication_version, + }; + let payload = match serde_json::to_vec(&receipt) { + Ok(payload) => payload, + Err(error) => return protected_git_denied(error), + }; + if let Err(error) = operation.commit(&payload).await { + return protected_git_denied(error); + } + } + Err(error) => return protected_git_denied(error), + } + } else { + // After the one-way cutover, non-Enforce modes preserve legacy + // authorization semantics but must still publish through the + // PostgreSQL visibility CAS. This is intentionally not a protected + // authorization receipt: Shadow and VerifyOnly remain + // non-authoritative, while the storage authority never regresses. + let Some(policy_fence) = ctx.policy_fence.as_ref() else { + return protected_git_denied("Git policy fence unavailable"); + }; + let mut transaction = match state.db.begin_transaction().await { + Ok(transaction) => transaction, + Err(error) => return protected_git_denied(error), + }; + match buzz_db::protected_publication::compare_and_publish_git( + &mut transaction, + buzz_db::protected_publication::GitPublicationRequest { + community_id: ctx.tenant.community(), + repo_id: &ctx.repo_id, + owner_pubkey: &ctx.owner, + expected: expected.as_ref(), + manifest_sha256, + pusher_pubkey: &ctx.pusher.to_bytes(), + policy: policy_fence, + }, + ) + .await + { + Ok(GitPublicationOutcome::Published(_)) => { + if let Err(error) = transaction.commit().await { + return protected_git_denied(error); + } + } + Ok(GitPublicationOutcome::Conflict) => { + return ( + StatusCode::CONFLICT, + "push superseded by a concurrent writer; pull and retry", + ) + .into_response() + } + Err(error) => return protected_git_denied(error), + } + } + } + // Derived after CAS: kind:30618 ref-state event over the *committed* // manifest's refs/head. Spec §Implementation Correspondence: // "kind:30618 is derived after CAS, never the commit." We emit only @@ -1899,7 +2656,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { (Some(before), Some(after)) => before != after, _ => true, // first push (parent None) or impossible-shape after key → publish }; - if manifest_changed { + if manifest_changed && committed_now && !ctx.protected_authority.is_enforcing() { let inputs = RefStateInputs { repo_id: &ctx.repo_id, head: &success.manifest.head, @@ -1925,24 +2682,13 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { &stored, ) .await; - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - manifest = %success.manifest_key, - "kind:30618 published (derived after CAS)" - ); + info!("kind:30618 published (derived after CAS)"); } Ok((_, false)) => { - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - "kind:30618 deduplicated by relay db" - ); + info!("kind:30618 deduplicated by relay db"); } Err(e) => { warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, error = %e, "kind:30618 insert failed; push remains durable in object store" ); @@ -1951,8 +2697,6 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { } Err(e) => { warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, error = %e, "kind:30618 build failed; push remains durable in object store" ); @@ -1989,10 +2733,33 @@ mod track_c_tests { use crate::api::git::manifest::Manifest; use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, VecDeque}; use std::io::Write; use std::process::Output; + struct ScriptedGitReadAuthority { + decisions: std::sync::Mutex>, + } + + impl ScriptedGitReadAuthority { + fn new(decisions: impl IntoIterator) -> Self { + Self { + decisions: std::sync::Mutex::new(decisions.into_iter().collect()), + } + } + } + + #[async_trait] + impl GitReadReleaseAuthority for ScriptedGitReadAuthority { + async fn release(&self) -> bool { + self.decisions + .lock() + .expect("scripted Git authority lock") + .pop_front() + .unwrap_or(false) + } + } + fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() } @@ -2132,6 +2899,79 @@ mod track_c_tests { assert!(remote.join("refs/heads/master").exists()); } + #[tokio::test] + async fn legacy_buffered_response_preserves_exact_body_framing() { + let bytes = b"legacy git advertisement".to_vec(); + let body = guard_git_buffered_body( + bytes.clone(), + Arc::new(ScriptedGitReadAuthority::new([true])), + ); + assert_eq!( + axum::body::to_bytes(body, usize::MAX) + .await + .expect("collect legacy body") + .as_ref(), + bytes + ); + } + + #[tokio::test] + async fn buffered_git_read_denies_membership_loss_before_first_body_poll() { + let body = guard_git_buffered_body( + b"must not be emitted".to_vec(), + Arc::new(ScriptedGitReadAuthority::new([false])), + ); + assert!(axum::body::to_bytes(body, usize::MAX).await.is_err()); + } + + #[tokio::test] + async fn streaming_git_read_denies_membership_loss_between_chunks() { + use futures_util::StreamExt; + + let source = futures_util::stream::iter([ + Ok(bytes::Bytes::from_static(b"first")), + Ok(bytes::Bytes::from_static(b"second")), + ]); + let stream = guard_git_read_stream( + source, + Arc::new(ScriptedGitReadAuthority::new([true, false])), + ); + futures_util::pin_mut!(stream); + assert_eq!( + stream + .next() + .await + .expect("first outcome") + .expect("first chunk"), + bytes::Bytes::from_static(b"first") + ); + assert_eq!( + stream + .next() + .await + .expect("denial outcome") + .expect_err("second chunk must be fenced") + .kind(), + std::io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn git_stream_revalidates_success_error_and_eof_outcomes() { + let completed = [ + std::task::Poll::Ready(Some(Ok::(7))), + std::task::Poll::Ready(Some(Err::("backend error"))), + std::task::Poll::Ready(None), + ]; + for poll in completed { + assert!(release_ready_git_poll(poll, || Err::<(), _>(())).is_err()); + } + + let pending = release_ready_git_poll::(std::task::Poll::Pending, || Err(())) + .expect("pending emits no outcome and does not consult the release fence"); + assert!(pending.is_pending()); + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index b2f633b33e..70c8166277 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -18,11 +18,30 @@ use axum::{ }; use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; +use buzz_auth::{AuthTransport, AuthorizationCapability, VerifiedEvidenceAdapter}; use buzz_core::tenant::TenantContext; -use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; +use buzz_media::{ + BlobDescriptor, MediaError, PreparedUpload, UploadAttribution, UploadNetworkInfo, + UploadPublicationMode, +}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use crate::authorization_runtime::executor::{ + begin_authorized_operation, AuthorizedOperationStart, ProtectedOperationId, +}; +use crate::authorization_runtime::transport::{authorize_if_configured, ProtectedAuthorization}; use crate::state::AppState; +fn stable_media_correlation(proof: &buzz_auth::VerifiedNostrProof) -> uuid::Uuid { + let fingerprint = proof.operation_binding().fingerprint(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&fingerprint[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + uuid::Uuid::from_bytes(bytes) +} + /// Axum extractor that validates Blossom auth, the BUD-11 hash binding, and /// relay membership (NIP-43, when enabled) from headers BEFORE the request /// body is read. This prevents unauthenticated clients from forcing the @@ -37,6 +56,7 @@ pub(crate) struct AuthenticatedUpload { /// door in `bridge.rs`. Server-resolved, never client-supplied. tenant: TenantContext, route_mode: UploadRouteMode, + protected_authority: Arc, _upload_permit: UploadPermit, } @@ -61,6 +81,40 @@ fn upload_route_mode(path: &str) -> Result { struct MediaReadAuth { tenant: TenantContext, + protected_authority: Option>, +} + +fn protected_media_denied(error: impl std::fmt::Display) -> MediaError { + tracing::warn!(error = %error, "media: protected authorization denied"); + MediaError::Unauthorized +} + +fn release_media_fetched( + authority: &Option>, + value: T, +) -> Result { + release_media_outcome( + authority + .as_deref() + .map(|authority| authority as &dyn crate::connection::OutboundReleaseFence), + value, + ) +} + +fn release_media_outcome( + authority: Option<&dyn crate::connection::OutboundReleaseFence>, + value: T, +) -> Result { + if authority.is_some_and(|authority| !authority.release()) { + return Err(MediaError::Unauthorized); + } + Ok(value) +} + +impl buzz_media::UploadCommitGuard for ProtectedAuthorization { + fn revalidate(&self) -> Result<(), MediaError> { + ProtectedAuthorization::revalidate(self).map_err(protected_media_denied) + } } async fn verify_media_corporate_identity( @@ -68,52 +122,98 @@ async fn verify_media_corporate_identity( tenant: &TenantContext, headers: &HeaderMap, pubkey: nostr::PublicKey, -) -> Result { +) -> Result, MediaError> { let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( + state, + tenant.community(), headers, - &state.config.corporate_identity, - ); - crate::corporate_identity::verify_corporate_identity( + ) + .map_err(protected_media_denied)?; + match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await - .map_err(|e| { - tracing::warn!(pubkey = %pubkey.to_hex(), error = %e, "media: corporate identity denied"); - if e.status_code() == StatusCode::UNAUTHORIZED { - MediaError::Unauthorized - } else { - MediaError::RelayMembershipRequired + { + Ok(proof) => Ok(Some(proof)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational media identity verification unavailable"); + Ok(None) } - }) + Err(error) => { + tracing::warn!(error = ?error, "media: corporate identity denied"); + if error.status_code() == StatusCode::UNAUTHORIZED { + Err(MediaError::Unauthorized) + } else { + Err(MediaError::RelayMembershipRequired) + } + } + } } -async fn finalize_media_corporate_identity( +fn seal_media_assertion( state: &AppState, tenant: &TenantContext, - pubkey: nostr::PublicKey, - proof: crate::corporate_identity::CorporateIdentityProof, -) -> Result<(), MediaError> { - crate::corporate_identity::finalize_corporate_identity( + proof: Option<&crate::corporate_identity::CorporateIdentityProof>, + transport: AuthTransport, +) -> Result>, MediaError> { + let Some(proof) = proof else { + return Ok(None); + }; + match crate::corporate_identity::current_verified_assertion_for_proof( state, - tenant.community(), - pubkey, proof, - ) - .await - .map(|_| ()) - .map_err(|e| { - tracing::warn!(pubkey = %pubkey.to_hex(), error = %e, "media: corporate identity finalization denied"); - if e.status_code() == StatusCode::UNAUTHORIZED { - MediaError::Unauthorized - } else { - MediaError::RelayMembershipRequired + tenant.community(), + transport, + ) { + Ok(assertion) => Ok(assertion.map(Arc::new)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = %error, "observational media assertion sealing unavailable"); + Ok(None) } - }) + Err(error) => Err(protected_media_denied(error)), + } +} + +async fn finalize_media_corporate_identity( + state: &AppState, + tenant: &TenantContext, + pubkey: nostr::PublicKey, + proof: Option, +) -> Result<(), MediaError> { + if crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(()); + } + let Some(proof) = proof else { + return Ok(()); + }; + crate::corporate_identity::finalize_corporate_identity(state, tenant.community(), pubkey, proof) + .await + .map(|_| ()) + .map_err(|e| { + tracing::warn!(error = ?e, "media: corporate identity finalization denied"); + if e.status_code() == StatusCode::UNAUTHORIZED { + MediaError::Unauthorized + } else { + MediaError::RelayMembershipRequired + } + }) } const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); @@ -189,6 +289,24 @@ fn acquire_upload_permit( }) } +fn acquire_protected_upload_permit( + state: &AppState, + community_id: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, + require_authority: impl FnOnce() -> Result<(), MediaError>, +) -> Result { + require_authority()?; + if upload_rate_limited(state, community_id, pubkey) { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") + .increment(1); + return Err(MediaError::UploadRateLimitExceeded); + } + acquire_upload_permit(state, community_id, pubkey).inspect_err(|_| { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") + .increment(1); + }) +} + impl FromRequestParts> for AuthenticatedUpload { type Rejection = MediaError; @@ -244,14 +362,18 @@ impl FromRequestParts> for AuthenticatedUpload { return Err(MediaError::HashMismatch); } - // 4. Validate X-SHA-256 matches at least one x tag in the auth event - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash))); - if !has_matching_x { - return Err(MediaError::HashMismatch); - } + // 4. Full exact-operation verification in the sealed-evidence adapter. + // This rechecks signature, upload verb, hash, server, and age together; + // a different valid kind:24242 event cannot be substituted afterward. + let verified_blossom = VerifiedEvidenceAdapter::new() + .verify_blossom_upload( + tenant.community(), + &auth_event, + claimed_hash, + Some(tenant.host()), + 3600, + ) + .map_err(protected_media_denied)?; // 5. Relay membership gate (NIP-43). Blossom auth proves the signer // authorized this exact upload hash for this server; NIP-43 answers @@ -272,15 +394,47 @@ impl FromRequestParts> for AuthenticatedUpload { ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; - if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") - .increment(1); - return Err(MediaError::UploadRateLimitExceeded); - } - let upload_permit = acquire_upload_permit(state, tenant.community(), &auth_event.pubkey) - .inspect_err(|_| { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") - .increment(1); + let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_owner( + auth_event.pubkey, + auth_tag, + ) { + Some(owner) => VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_blossom, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + owner, + auth_event.pubkey, + None, + true, + ), + ) + .map_err(protected_media_denied)?, + None => verified_blossom, + }; + let correlation_id = stable_media_correlation(&verified_blossom); + let verified_assertion = seal_media_assertion( + state, + &tenant, + identity_proof.as_ref(), + AuthTransport::MediaUpload, + )?; + let protected_authority = Arc::new( + authorize_if_configured( + state, + Arc::new(verified_blossom), + verified_assertion, + AuthorizationCapability::MediaWrite, + correlation_id, + "media.upload", + ) + .await + .map_err(protected_media_denied)?, + ); + let upload_permit = + acquire_protected_upload_permit(state, tenant.community(), &auth_event.pubkey, || { + protected_authority + .revalidate() + .map_err(protected_media_denied) })?; finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) .await?; @@ -289,6 +443,7 @@ impl FromRequestParts> for AuthenticatedUpload { auth_event, tenant, route_mode, + protected_authority, _upload_permit: upload_permit, }) } @@ -366,6 +521,52 @@ pub async fn upload_blob( body: axum::body::Body, ) -> Result, MediaError> { let attribution = upload_attribution(&state, &auth, &headers).await; + let visibility = state + .db + .protected_object_authority( + auth.tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Media, + ) + .await + .map_err(|_| MediaError::Internal)?; + let mut postgresql_visibility = visibility.state + == buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql; + if auth.protected_authority.is_enforcing() && !postgresql_visibility { + crate::api::media_migration::require_reconciled_authority(&state, &auth.tenant) + .await + .map_err(|error| { + tracing::warn!(%error, "protected media authority migration unavailable"); + MediaError::Unauthorized + })?; + postgresql_visibility = true; + } + let publication_mode = if postgresql_visibility { + UploadPublicationMode::ProtectedStaging + } else { + UploadPublicationMode::Legacy + }; + let legacy_visibility = if publication_mode == UploadPublicationMode::ProtectedStaging { + None + } else { + let guard = state + .db + .begin_legacy_visibility_write( + auth.tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Media, + ) + .await + .map_err(|error| { + tracing::warn!(%error, "legacy media publication is fenced"); + MediaError::Unauthorized + })?; + crate::api::media_migration::require_legacy_sentinel_absent(&state, &auth.tenant) + .await + .map_err(|error| { + tracing::warn!(%error, "legacy media publication is permanently fenced"); + MediaError::Unauthorized + })?; + Some(guard) + }; if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); @@ -374,12 +575,14 @@ pub async fn upload_blob( // Probe actual bytes without trusting Content-Type. Keep the chunks used // for the bounded probe and replay them into the selected pipeline so the // stored/hash-verified body remains byte-identical. - use futures_util::StreamExt; const SNIFF_BYTES: usize = 4096; let mut source = body.into_data_stream(); let mut replay_chunks = Vec::new(); let mut sniff = Vec::with_capacity(SNIFF_BYTES); while sniff.len() < SNIFF_BYTES { + auth.protected_authority + .revalidate() + .map_err(protected_media_denied)?; match source.next().await { Some(Ok(chunk)) => { let needed = SNIFF_BYTES - sniff.len(); @@ -390,14 +593,28 @@ pub async fn upload_blob( None => break, } } - let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - - let mut descriptor = if should_stream_as_video(&sniff) { + let stream_authority = Arc::clone(&auth.protected_authority); + let guarded_source = source.map(move |item| { + stream_authority.revalidate().map_err(|error| { + axum::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + error.to_string(), + )) + })?; + item + }); + let replay = + futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(guarded_source); + + let prepared = if should_stream_as_video(&sniff) { // Video path: stream body directly to disk — never fully buffered in RAM. let content_length = headers .get("content-length") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); + auth.protected_authority + .revalidate() + .map_err(protected_media_denied)?; buzz_media::process_video_upload( &state.media_storage, &state.config.media, @@ -406,6 +623,8 @@ pub async fn upload_blob( replay, content_length, attribution, + auth.protected_authority.as_ref(), + publication_mode, ) .await? } else { @@ -429,14 +648,19 @@ pub async fn upload_blob( ); if is_image { - buzz_media::process_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, + auth.protected_authority + .revalidate() + .map_err(protected_media_denied)?; + buzz_media::process_upload(buzz_media::upload::BufferedUploadRequest { + storage: &state.media_storage, + config: &state.config.media, + ctx: &auth.tenant, + auth_event: &auth.auth_event, + body: bytes, attribution, - ) + commit_guard: auth.protected_authority.as_ref(), + publication_mode, + }) .await? } else if auth.route_mode == UploadRouteMode::LegacyMedia { let mime = infer::get(&bytes) @@ -444,24 +668,39 @@ pub async fn upload_blob( .unwrap_or_else(|| "application/octet-stream".to_string()); return Err(MediaError::DisallowedContentType(mime)); } else { - buzz_media::process_file_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, + auth.protected_authority + .revalidate() + .map_err(protected_media_denied)?; + buzz_media::process_file_upload(buzz_media::upload::BufferedUploadRequest { + storage: &state.media_storage, + config: &state.config.media, + ctx: &auth.tenant, + auth_event: &auth.auth_event, + body: bytes, attribution, - ) + commit_guard: auth.protected_authority.as_ref(), + publication_mode, + }) .await? } }; + let mut prepared = prepared; rewrite_descriptor_urls_for_tenant( - &mut descriptor, + &mut prepared.descriptor, &state.config.relay_url, auth.tenant.host(), ); + let descriptor = + commit_media_publication(&state, &auth, prepared, postgresql_visibility).await?; + if let Some(legacy_visibility) = legacy_visibility { + legacy_visibility.commit().await.map_err(|error| { + tracing::error!(%error, "legacy media publication fence commit failed"); + MediaError::Internal + })?; + } + // Normalize MIME to a known set to bound label cardinality. let mime_label = match descriptor.mime_type.as_str() { "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4" => { @@ -472,35 +711,134 @@ pub async fn upload_blob( metrics::counter!( "buzz_media_uploads_total", "mime" => mime_label.to_owned(), - "community" => auth.tenant.host().to_owned() + "community" => crate::metrics::community_label(auth.tenant.community()) ) .increment(1); // Audit via bounded channel — same pattern as event audit. - if let Some(audit_tx) = &state.audit_tx { - let desc = descriptor.clone(); - if let Err(e) = audit_tx - .send(NewAuditEntry { - community_id: auth.tenant.community(), - action: AuditAction::MediaUploaded, - actor_pubkey: Some(auth.auth_event.pubkey.to_bytes().to_vec()), - object_id: Some(desc.sha256.clone()), - detail: serde_json::json!({ - "sha256": desc.sha256, - "size": desc.size, - "mime": desc.mime_type, - }), - }) - .await - { - tracing::error!("Media audit channel closed — entry lost: {e}"); - metrics::counter!("buzz_audit_send_errors_total").increment(1); + if crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(auth.tenant.community())), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_ok() + { + if let Some(audit_tx) = &state.audit_tx { + let desc = descriptor.clone(); + if let Err(e) = audit_tx + .send(NewAuditEntry { + community_id: auth.tenant.community(), + action: AuditAction::MediaUploaded, + actor_pubkey: Some(auth.auth_event.pubkey.to_bytes().to_vec()), + object_id: Some(desc.sha256.clone()), + detail: serde_json::json!({ + "sha256": desc.sha256, + "size": desc.size, + "mime": desc.mime_type, + }), + }) + .await + { + tracing::error!("Media audit channel closed — entry lost: {e}"); + metrics::counter!("buzz_audit_send_errors_total").increment(1); + } } } Ok(Json(descriptor)) } +async fn commit_media_publication( + state: &AppState, + auth: &AuthenticatedUpload, + prepared: PreparedUpload, + postgresql_visibility: bool, +) -> Result { + if !postgresql_visibility { + return Ok(prepared.descriptor); + } + let PreparedUpload { + descriptor, + metadata, + object_key, + thumbnail_key, + } = prepared; + let metadata_json = serde_json::to_value(&metadata).map_err(|_| MediaError::Internal)?; + let publication = buzz_db::protected_publication::MediaPublication { + sha256: descriptor.sha256.clone(), + object_key, + extension: metadata.ext.clone(), + mime_type: metadata.mime_type.clone(), + object_size: metadata.size, + metadata: metadata_json, + thumbnail_key, + publication_version: 1, + }; + if auth.protected_authority.is_enforcing() { + let operation_id = ProtectedOperationId::derive( + auth.tenant.community(), + "media.upload", + auth.auth_event.id.as_bytes(), + ) + .map_err(protected_media_denied)?; + let mut request_digest = Sha256::new(); + request_digest.update(b"buzz-media-publication-v1"); + request_digest.update(descriptor.sha256.as_bytes()); + request_digest.update(metadata.ext.as_bytes()); + request_digest.update(metadata.mime_type.as_bytes()); + request_digest.update(metadata.size.to_be_bytes()); + let request_fingerprint: [u8; 32] = request_digest.finalize().into(); + let permit = auth + .protected_authority + .seal_postgres_mutation(operation_id, "media.upload", request_fingerprint) + .map_err(protected_media_denied)? + .ok_or(MediaError::Unauthorized)?; + match begin_authorized_operation(state, permit) + .await + .map_err(protected_media_denied)? + { + AuthorizedOperationStart::Replay(payload) => { + serde_json::from_slice(&payload).map_err(|_| MediaError::Internal) + } + AuthorizedOperationStart::Execute(mut operation) => { + buzz_db::protected_publication::publish_media( + operation.transaction(), + auth.tenant.community(), + &publication, + ) + .await + .map_err(protected_media_denied)?; + let payload = serde_json::to_vec(&descriptor).map_err(|_| MediaError::Internal)?; + operation + .commit(&payload) + .await + .map_err(protected_media_denied)?; + Ok(descriptor) + } + } + } else { + // Storage authority remains PostgreSQL after cutover even when the + // protected authorization mode is Off, Shadow, or VerifyOnly. Preserve + // legacy authorization semantics, publish no sidecar, and commit the + // immutable descriptor through the PostgreSQL visibility transaction. + let mut transaction = state + .db + .begin_transaction() + .await + .map_err(protected_media_denied)?; + buzz_db::protected_publication::publish_media( + &mut transaction, + auth.tenant.community(), + &publication, + ) + .await + .map_err(protected_media_denied)?; + transaction.commit().await.map_err(protected_media_denied)?; + Ok(descriptor) + } +} + pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") || config_relay_url.trim_start().starts_with("https://") @@ -550,13 +888,27 @@ async fn authenticate_media_read( ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; - if !state.config.require_media_get_auth { - return Ok(MediaReadAuth { tenant }); + let enforcing = + crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::ProtectedEnforce; + if !state.config.require_media_get_auth && !enforcing { + return Ok(MediaReadAuth { + tenant, + protected_authority: None, + }); } let auth_event = extract_blossom_auth(headers)?; let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); - buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; + let verified_blossom = VerifiedEvidenceAdapter::new() + .verify_blossom_download( + tenant.community(), + &auth_event, + sha256, + Some(tenant.host()), + 3600, + ) + .map_err(protected_media_denied)?; let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); let identity_proof = @@ -569,9 +921,51 @@ async fn authenticate_media_read( ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; + let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_owner( + auth_event.pubkey, + auth_tag, + ) { + Some(owner) => VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_blossom, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + owner, + auth_event.pubkey, + None, + true, + ), + ) + .map_err(protected_media_denied)?, + None => verified_blossom, + }; + let correlation_id = stable_media_correlation(&verified_blossom); + let verified_assertion = seal_media_assertion( + state, + &tenant, + identity_proof.as_ref(), + AuthTransport::MediaDownload, + )?; + let protected_authority = Arc::new( + authorize_if_configured( + state, + Arc::new(verified_blossom), + verified_assertion, + AuthorizationCapability::MediaRead, + correlation_id, + "media.read", + ) + .await + .map_err(protected_media_denied)?, + ); + protected_authority + .revalidate() + .map_err(protected_media_denied)?; finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof).await?; - Ok(MediaReadAuth { tenant }) + Ok(MediaReadAuth { + tenant, + protected_authority: Some(protected_authority), + }) } fn blob_cache_control(require_auth: bool) -> &'static str { @@ -668,7 +1062,14 @@ pub async fn get_blob( ) -> Result { validate_media_path(&sha256_ext)?; let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; - serve_blob_for_tenant(&state, &media_auth.tenant, &sha256_ext, &req_headers).await + serve_blob_for_tenant( + &state, + &media_auth.tenant, + &sha256_ext, + &req_headers, + media_auth.protected_authority, + ) + .await } /// Serve a validated blob from an already-authorized tenant context. @@ -681,40 +1082,17 @@ pub(crate) async fn serve_blob_for_tenant( tenant: &TenantContext, sha256_ext: &str, req_headers: &HeaderMap, + protected_authority: Option>, ) -> Result { validate_media_path(sha256_ext)?; - let cache_control = blob_cache_control(state.config.require_media_get_auth); + if let Some(authority) = &protected_authority { + authority.revalidate().map_err(protected_media_denied)?; + } + let cache_control = + blob_cache_control(state.config.require_media_get_auth || protected_authority.is_some()); - // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. - let content_type = if sha256_ext.ends_with(".thumb.jpg") { - let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(sha256_ext); - let _ = state - .media_storage - .read_sidecar_mime(tenant, parent_hash) - .await - .ok_or(MediaError::NotFound)?; - "image/jpeg".to_string() - } else { - // For explicit paths (hash.ext), verify the requested extension matches - // the sidecar's canonical extension — sidecar is authoritative. - let sidecar_mime = state - .media_storage - .read_sidecar_mime(tenant, sha256_ext) - .await - .ok_or(MediaError::NotFound)?; - if sha256_ext.contains('.') { - let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); - let sidecar = state - .media_storage - .get_sidecar(tenant, sha256_ext.split('.').next().unwrap_or(sha256_ext)) - .await - .map_err(|_| MediaError::NotFound)?; - if requested_ext != sidecar.ext { - return Err(MediaError::NotFound); - } - } - sidecar_mime - }; + let (content_type, key) = + resolve_visible_media(state, tenant, sha256_ext, &protected_authority).await?; // Images and video render inline; generic files force download. This is the // primary defence for non-previewable types — combined with `nosniff` and @@ -726,8 +1104,6 @@ pub(crate) async fn serve_blob_for_tenant( "attachment" }; - let key = resolve_s3_key(&state.media_storage, tenant, sha256_ext).await?; - // Parse optional Range header. let range_header = req_headers .get(header::RANGE) @@ -741,13 +1117,24 @@ pub(crate) async fn serve_blob_for_tenant( match single_range { None => { // Full response — 200 OK. Stream from S3 — never loads full blob into RAM. - let total = state - .media_storage - .head_with_metadata(&key) - .await? - .ok_or(MediaError::NotFound)? - .size; - let stream = state.media_storage.get_stream(&key).await?; + let total = release_media_fetched( + &protected_authority, + state.media_storage.head_with_metadata(&key).await, + )?? + .ok_or(MediaError::NotFound)? + .size; + let stream = release_media_fetched( + &protected_authority, + state.media_storage.get_stream(&key).await, + )??; + let stream = stream.map(move |item| { + if let Some(authority) = &protected_authority { + authority + .release_fetched(()) + .map_err(protected_media_denied)?; + } + item + }); let resp = axum::response::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &content_type) @@ -763,17 +1150,22 @@ pub(crate) async fn serve_blob_for_tenant( } Some(range_str) => { // S3-native single-range response, capped to bound request memory. - let total = state - .media_storage - .head_with_metadata(&key) - .await? - .ok_or(MediaError::NotFound)? - .size; + let total = release_media_fetched( + &protected_authority, + state.media_storage.head_with_metadata(&key).await, + )?? + .ok_or(MediaError::NotFound)? + .size; let parsed = parse_byte_range(&range_str, total); match parsed { Some((start, end)) => { if start >= total { + if let Some(authority) = &protected_authority { + authority + .release_fetched(()) + .map_err(protected_media_denied)?; + } return axum::response::Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{total}")) @@ -785,7 +1177,13 @@ pub(crate) async fn serve_blob_for_tenant( let end = end .min(start.saturating_add(MAX_RANGE_CHUNK - 1)) .min(total.saturating_sub(1)); - let chunk = state.media_storage.get_range(&key, start, end).await?; + if let Some(authority) = &protected_authority { + authority.revalidate().map_err(protected_media_denied)?; + } + let chunk = release_media_fetched( + &protected_authority, + state.media_storage.get_range(&key, start, end).await, + )??; let content_range = format!("bytes {start}-{end}/{total}"); Ok(axum::response::Response::builder() @@ -801,11 +1199,18 @@ pub(crate) async fn serve_blob_for_tenant( .body(axum::body::Body::from(chunk)) .map_err(|_| MediaError::Internal)?) } - None => Ok(axum::response::Response::builder() - .status(StatusCode::RANGE_NOT_SATISFIABLE) - .header(header::CONTENT_RANGE, format!("bytes */{total}")) - .body(axum::body::Body::empty()) - .map_err(|_| MediaError::Internal)?), + None => { + if let Some(authority) = &protected_authority { + authority + .release_fetched(()) + .map_err(protected_media_denied)?; + } + Ok(axum::response::Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .body(axum::body::Body::empty()) + .map_err(|_| MediaError::Internal)?) + } } } } @@ -861,42 +1266,30 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let require_media_get_auth = state.config.require_media_get_auth; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; + if let Some(authority) = &media_auth.protected_authority { + authority.revalidate().map_err(protected_media_denied)?; + } let tenant = media_auth.tenant; - let cache_control = blob_cache_control(require_media_get_auth); - - // Sidecar gate FIRST — reject before any blob I/O. - let content_type = if sha256_ext.ends_with(".thumb.jpg") { - let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext); - let _ = state - .media_storage - .read_sidecar_mime(&tenant, parent_hash) - .await - .ok_or(MediaError::NotFound)?; - "image/jpeg".to_string() - } else { - let sidecar_mime = state - .media_storage - .read_sidecar_mime(&tenant, &sha256_ext) - .await - .ok_or(MediaError::NotFound)?; - if sha256_ext.contains('.') { - let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); - let sidecar = state - .media_storage - .get_sidecar(&tenant, sha256_ext.split('.').next().unwrap_or(&sha256_ext)) - .await - .map_err(|_| MediaError::NotFound)?; - if requested_ext != sidecar.ext { - return Err(MediaError::NotFound); - } - } - sidecar_mime - }; + let cache_control = blob_cache_control( + state.config.require_media_get_auth || media_auth.protected_authority.is_some(), + ); - let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?; - match state.media_storage.head_with_metadata(&key).await? { + let (content_type, key) = resolve_visible_media( + &state, + &tenant, + &sha256_ext, + &media_auth.protected_authority, + ) + .await?; + if let Some(authority) = &media_auth.protected_authority { + authority.revalidate().map_err(protected_media_denied)?; + } + let metadata = release_media_fetched( + &media_auth.protected_authority, + state.media_storage.head_with_metadata(&key).await, + )??; + match metadata { Some(meta) => { let size_str = meta.size.to_string(); Ok(( @@ -914,6 +1307,124 @@ pub async fn head_blob( } } +pub(crate) async fn resolve_visible_media( + state: &AppState, + tenant: &TenantContext, + sha256_ext: &str, + authority: &Option>, +) -> Result<(String, String), MediaError> { + let enforcing = + crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::ProtectedEnforce; + let mut visibility = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Media, + ) + .await + .map_err(|_| MediaError::Internal)?; + if enforcing + && visibility.state + != buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql + { + crate::api::media_migration::require_reconciled_authority(state, tenant) + .await + .map_err(|error| { + tracing::warn!(%error, "protected media authority migration unavailable"); + MediaError::Unauthorized + })?; + visibility = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Media, + ) + .await + .map_err(|_| MediaError::Internal)?; + } + if visibility.state == buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql + { + // The one-way cutover selects PostgreSQL visibility in every mode. + // Off, Shadow, and VerifyOnly preserve their legacy authorization + // decision but never regress to mutable sidecars after the sentinel. + let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); + let publication = release_media_fetched( + authority, + state.db.media_publication(tenant.community(), sha256).await, + )? + .map_err(protected_media_denied)? + .ok_or(MediaError::NotFound)?; + if sha256_ext.ends_with(".thumb.jpg") { + return Ok(( + "image/jpeg".to_string(), + publication.thumbnail_key.ok_or(MediaError::NotFound)?, + )); + } + if sha256_ext.contains('.') { + let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); + if requested_ext != publication.extension { + return Err(MediaError::NotFound); + } + } + return Ok((publication.mime_type, publication.object_key)); + } + + if enforcing { + return Err(MediaError::Unauthorized); + } + crate::api::media_migration::require_legacy_sentinel_absent(state, tenant) + .await + .map_err(|error| { + tracing::warn!(%error, "legacy media visibility is permanently fenced"); + MediaError::Unauthorized + })?; + // Before cutover, Off, Shadow, and VerifyOnly retain the exact tenant-sidecar + // visibility contract. A PostgreSQL marker above is monotonic and never + // falls back to the mutable sidecar state. + let content_type = if sha256_ext.ends_with(".thumb.jpg") { + let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(sha256_ext); + let _ = release_media_fetched( + authority, + state + .media_storage + .read_sidecar_mime(tenant, parent_hash) + .await, + )? + .ok_or(MediaError::NotFound)?; + "image/jpeg".to_string() + } else { + let sidecar_mime = release_media_fetched( + authority, + state + .media_storage + .read_sidecar_mime(tenant, sha256_ext) + .await, + )? + .ok_or(MediaError::NotFound)?; + if sha256_ext.contains('.') { + let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); + let sidecar = release_media_fetched( + authority, + state + .media_storage + .get_sidecar(tenant, sha256_ext.split('.').next().unwrap_or(sha256_ext)) + .await, + )? + .map_err(|_| MediaError::NotFound)?; + if requested_ext != sidecar.ext { + return Err(MediaError::NotFound); + } + } + sidecar_mime + }; + let key = release_media_fetched( + authority, + resolve_s3_key(&state.media_storage, tenant, sha256_ext).await, + )??; + Ok((content_type, key)) +} + /// Resolve the S3 key from a URL path segment. /// /// - `sha256.ext` → used as-is (already validated by `validate_media_path`) @@ -970,6 +1481,7 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use axum::{ @@ -980,6 +1492,30 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; + struct ScriptedMediaFence(AtomicBool); + + impl crate::connection::OutboundReleaseFence for ScriptedMediaFence { + fn release(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + #[test] + fn media_outcomes_release_only_after_post_fetch_authority_check() { + let fence = ScriptedMediaFence(AtomicBool::new(true)); + let success = release_media_outcome(Some(&fence), Ok::<_, &'static str>(Some("blob"))) + .expect("current authority releases fetched success"); + assert_eq!(success, Ok(Some("blob"))); + + fence.0.store(false, Ordering::SeqCst); + for fetched in [Ok(Some("blob")), Ok(None), Err("storage unavailable")] { + assert!(matches!( + release_media_outcome(Some(&fence), fetched), + Err(MediaError::Unauthorized) + )); + } + } + const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; #[test] @@ -1271,6 +1807,31 @@ mod tests { drop(permit_a); } + #[tokio::test] + async fn denied_protected_upload_consumes_no_rate_or_concurrency_state() { + let state = test_state().await; + let pubkey = nostr::Keys::generate().public_key(); + let community = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xCA5)); + let key = (community, pubkey.to_bytes()); + + assert!(matches!( + acquire_protected_upload_permit(&state, community, &pubkey, || { + Err(MediaError::Unauthorized) + }), + Err(MediaError::Unauthorized) + )); + assert!(!state.media_upload_rate_limiter.contains_key(&key)); + assert!(!state.media_uploads_in_flight.contains_key(&key)); + + assert!( + !upload_rate_limited(&state, community, &pubkey), + "the first legacy retry must retain its full rate budget" + ); + let permit = acquire_upload_permit(&state, community, &pubkey) + .expect("the first legacy retry must retain its concurrency slot"); + drop(permit); + } + #[test] fn test_validate_media_path_bare_hash() { assert!(validate_media_path(VALID_HASH).is_ok()); diff --git a/crates/buzz-relay/src/api/media_migration.rs b/crates/buzz-relay/src/api/media_migration.rs new file mode 100644 index 0000000000..ecabed8646 --- /dev/null +++ b/crates/buzz-relay/src/api/media_migration.rs @@ -0,0 +1,880 @@ +//! Validated one-way migration from media sidecars to PostgreSQL authority. + +use std::collections::BTreeMap; + +use buzz_core::tenant::TenantContext; +use buzz_db::protected_publication::MediaPublication; +use buzz_db::protected_visibility::{ProtectedObjectAuthorityState, ProtectedObjectSurface}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::state::AppState; + +const SENTINEL_FORMAT_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CutoverSentinel { + format_version: u32, + community_id: uuid::Uuid, + surface: String, + generation: u64, + imported_objects: u64, + inventory_sha256: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PreparationDisposition { + Begin, + Resume, + Verify, +} + +fn preparation_disposition( + authority: &buzz_db::protected_visibility::ProtectedObjectAuthority, + sentinel: Option<&CutoverSentinel>, +) -> anyhow::Result { + match authority.state { + ProtectedObjectAuthorityState::Legacy => { + if sentinel.is_some() { + anyhow::bail!("media cutover sentinel exists but PostgreSQL authority regressed"); + } + Ok(PreparationDisposition::Begin) + } + ProtectedObjectAuthorityState::Importing => { + if sentinel.is_some_and(|sentinel| sentinel.generation != authority.generation) { + anyhow::bail!("media resumed import generation conflicts with its sentinel"); + } + Ok(PreparationDisposition::Resume) + } + ProtectedObjectAuthorityState::PostgreSql => { + let sentinel = sentinel.ok_or_else(|| { + anyhow::anyhow!("media PostgreSQL authority is missing its sentinel") + })?; + validate_authority_snapshot(authority, sentinel)?; + Ok(PreparationDisposition::Verify) + } + } +} + +fn sentinel_key(community_id: buzz_core::CommunityId) -> String { + format!("_authority/{community_id}/media-v1.json") +} + +async fn read_sentinel( + state: &AppState, + community_id: buzz_core::CommunityId, +) -> anyhow::Result> { + let Some(bytes) = state + .media_storage + .get_optional(&sentinel_key(community_id)) + .await? + else { + return Ok(None); + }; + let sentinel: CutoverSentinel = serde_json::from_slice(&bytes)?; + if sentinel.format_version != SENTINEL_FORMAT_VERSION + || sentinel.community_id != *community_id.as_uuid() + || sentinel.surface != "media" + { + anyhow::bail!("media cutover sentinel does not match its domain and surface"); + } + validate_digest(&sentinel.inventory_sha256)?; + Ok(Some(sentinel)) +} + +async fn create_sentinel(state: &AppState, sentinel: &CutoverSentinel) -> anyhow::Result<()> { + let community_id = buzz_core::CommunityId::from_uuid(sentinel.community_id); + let body = serde_json::to_vec(sentinel)?; + match state + .media_storage + .put_create_only(&sentinel_key(community_id), &body, "application/json") + .await? + { + buzz_media::storage::CreateOnlyOutcome::Created => Ok(()), + buzz_media::storage::CreateOnlyOutcome::AlreadyExists => { + if read_sentinel(state, community_id).await?.as_ref() == Some(sentinel) { + Ok(()) + } else { + anyhow::bail!("media cutover sentinel conflicts with the prepared inventory") + } + } + } +} + +/// Prepare one domain's exact, resumable media visibility import before serving. +pub async fn prepare_postgres_authority( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + if state.restore_protection().is_some() { + anyhow::bail!( + "media cutover must complete before the protected restore anchor is provisioned" + ); + } + let authority = state + .db + .protected_object_authority(tenant.community(), ProtectedObjectSurface::Media) + .await?; + let existing_sentinel = read_sentinel(state, tenant.community()).await?; + let disposition = preparation_disposition(&authority, existing_sentinel.as_ref())?; + let authority = if disposition == PreparationDisposition::Begin { + state + .db + .begin_protected_object_import(tenant.community(), ProtectedObjectSurface::Media) + .await? + } else { + authority + }; + if disposition == PreparationDisposition::Verify { + let sentinel = existing_sentinel.ok_or_else(|| { + anyhow::anyhow!("media verified authority is missing its cutover sentinel") + })?; + return validate_authority_snapshot(&authority, &sentinel); + } + + let legacy = read_legacy_inventory(state, tenant).await?; + for publication in legacy.values() { + let mut transaction = state.db.begin_transaction().await?; + buzz_db::protected_publication::import_media_publication( + &mut transaction, + tenant.community(), + publication, + ) + .await?; + transaction.commit().await?; + } + let verified = read_legacy_inventory(state, tenant).await?; + if verified != legacy { + anyhow::bail!("media migration inventory changed during verification"); + } + let postgres = { + let mut transaction = state.db.begin_transaction().await?; + let rows = buzz_db::protected_publication::list_media_publications( + &mut transaction, + tenant.community(), + ) + .await?; + transaction.commit().await?; + rows.into_iter() + .map(|publication| (publication.sha256.clone(), publication)) + .collect::>() + }; + if postgres != legacy { + anyhow::bail!("media migration inventory parity failed"); + } + let inventory = media_inventory_digest(&postgres); + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: *tenant.community().as_uuid(), + surface: "media".into(), + generation: authority.generation, + imported_objects: postgres.len() as u64, + inventory_sha256: inventory.clone(), + }; + if let Some(existing) = existing_sentinel { + if existing != sentinel { + anyhow::bail!("media cutover sentinel does not match the resumed import"); + } + } else { + create_sentinel(state, &sentinel).await?; + } + state + .db + .finalize_protected_object_import( + tenant.community(), + ProtectedObjectSurface::Media, + authority.generation, + postgres.len() as u64, + &inventory, + ) + .await?; + Ok(()) +} + +/// Require a completed, reconciled authority without advancing migration state. +pub async fn require_reconciled_authority( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + let authority = state + .db + .protected_object_authority(tenant.community(), ProtectedObjectSurface::Media) + .await?; + if authority.state != ProtectedObjectAuthorityState::PostgreSql { + anyhow::bail!("media PostgreSQL authority has not completed preparation"); + } + let sentinel = read_sentinel(state, tenant.community()) + .await? + .ok_or_else(|| anyhow::anyhow!("media PostgreSQL authority sentinel is missing"))?; + validate_authority_snapshot(&authority, &sentinel) +} + +/// Refuse a legacy lane after the immutable cutover sentinel exists. This is +/// checked in every mode so a restored pre-cutover database cannot revive +/// stale object-store visibility. +pub async fn require_legacy_sentinel_absent( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result<()> { + if read_sentinel(state, tenant.community()).await?.is_some() { + anyhow::bail!("media legacy authority is permanently unavailable after cutover"); + } + Ok(()) +} + +fn validate_authority_snapshot( + authority: &buzz_db::protected_visibility::ProtectedObjectAuthority, + sentinel: &CutoverSentinel, +) -> anyhow::Result<()> { + if authority.state != ProtectedObjectAuthorityState::PostgreSql + || authority.generation != sentinel.generation + || authority.imported_objects != Some(sentinel.imported_objects) + || authority.inventory_sha256.as_deref() != Some(&sentinel.inventory_sha256) + { + anyhow::bail!("media PostgreSQL authority and cutover sentinel disagree"); + } + Ok(()) +} + +async fn read_legacy_inventory( + state: &AppState, + tenant: &TenantContext, +) -> anyhow::Result> { + let prefix = format!("_meta/{}/", tenant.community()); + let mut continuation = None; + let mut inventory = BTreeMap::new(); + loop { + let page = state + .media_storage + .list_page_with_prefix(prefix.clone(), continuation, 1000) + .await?; + for (key, _size) in page.objects { + let sha256 = key + .strip_prefix(&prefix) + .and_then(|value| value.strip_suffix(".json")) + .ok_or_else(|| anyhow::anyhow!("media migration found an invalid sidecar key"))?; + validate_digest(sha256)?; + let metadata = state.media_storage.get_sidecar(tenant, sha256).await?; + validate_extension(&metadata.ext)?; + let object_key = format!("{sha256}.{}", metadata.ext); + let head = state + .media_storage + .head_with_metadata(&object_key) + .await? + .ok_or_else(|| anyhow::anyhow!("media migration blob is missing"))?; + if head.size != metadata.size { + anyhow::bail!("media migration blob size does not match sidecar"); + } + let mut body = state.media_storage.get_stream(&object_key).await?; + let mut digest = Sha256::new(); + while let Some(chunk) = body.next().await { + digest.update(chunk?); + } + if hex::encode(digest.finalize()) != sha256 { + anyhow::bail!("media migration blob digest does not match its key"); + } + let thumbnail_key = format!("{sha256}.thumb.jpg"); + let thumbnail_key = state + .media_storage + .head(&thumbnail_key) + .await? + .then_some(thumbnail_key); + let publication = MediaPublication { + sha256: sha256.to_owned(), + object_key, + extension: metadata.ext.clone(), + mime_type: metadata.mime_type.clone(), + object_size: metadata.size, + metadata: serde_json::to_value(metadata)?, + thumbnail_key, + publication_version: 1, + }; + if inventory.insert(sha256.to_owned(), publication).is_some() { + anyhow::bail!("media migration sidecar inventory contains a duplicate"); + } + } + if !page.is_truncated { + break; + } + continuation = page.next_continuation_token; + if continuation.is_none() { + anyhow::bail!("media migration listing was truncated without a continuation token"); + } + } + Ok(inventory) +} + +fn validate_digest(value: &str) -> anyhow::Result<()> { + if value.len() != 64 + || !value + .chars() + .all(|character| matches!(character, '0'..='9' | 'a'..='f')) + { + anyhow::bail!("media migration sidecar digest is invalid"); + } + Ok(()) +} + +fn validate_extension(value: &str) -> anyhow::Result<()> { + if value.is_empty() + || value.len() > 16 + || !value + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + { + anyhow::bail!("media migration sidecar extension is invalid"); + } + Ok(()) +} + +fn media_inventory_digest(inventory: &BTreeMap) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-media-inventory-v1\0"); + for publication in inventory.values() { + digest.update(publication.sha256.as_bytes()); + digest.update([0]); + digest.update(publication.object_key.as_bytes()); + digest.update([0]); + digest.update(publication.mime_type.as_bytes()); + digest.update([0]); + digest.update(publication.object_size.to_be_bytes()); + digest.update([0]); + digest.update(serde_json::to_vec(&publication.metadata).unwrap_or_default()); + digest.update([0]); + if let Some(thumbnail) = &publication.thumbnail_key { + digest.update(thumbnail.as_bytes()); + } + digest.update([0]); + } + hex::encode(digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Arc; + + use crate::api::git::manifest::{pointer_key, Manifest, MANIFEST_VERSION}; + use crate::api::git::store::Precond; + use buzz_core::tenant::TenantContext; + use buzz_media::BlobMeta; + + async fn migration_test_state() -> (Arc, TenantContext, sqlx::PgPool) { + let mut config = crate::config::Config::from_env().expect("test configuration"); + if let Ok(database_url) = std::env::var("BUZZ_TEST_DATABASE_URL") { + config.database_url = database_url; + } + config.redis_url = "redis://127.0.0.1:6379".into(); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("test database"); + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("migrated test database"); + let community_uuid = uuid::Uuid::new_v4(); + let host = format!("migration-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .expect("test community"); + let tenant = + TenantContext::resolved(buzz_core::CommunityId::from_uuid(community_uuid), host); + 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), tenant, pool) + } + + #[test] + fn strict_legacy_key_components_reject_ambiguous_values() { + assert!(validate_digest(&"a".repeat(64)).is_ok()); + assert!(validate_digest(&"A".repeat(64)).is_err()); + assert!(validate_extension("webp").is_ok()); + assert!(validate_extension("../jpg").is_err()); + assert!(validate_extension("JPG").is_err()); + } + + #[test] + fn authority_snapshot_rejects_restore_regression() { + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: uuid::Uuid::nil(), + surface: "media".into(), + generation: 2, + imported_objects: 1, + inventory_sha256: "a".repeat(64), + }; + let authority = buzz_db::protected_visibility::ProtectedObjectAuthority { + generation: 1, + state: ProtectedObjectAuthorityState::Legacy, + imported_objects: None, + inventory_sha256: None, + }; + assert!(validate_authority_snapshot(&authority, &sentinel).is_err()); + } + + #[test] + fn migration_state_matrix_is_resumable_and_fail_closed() { + let sentinel = CutoverSentinel { + format_version: SENTINEL_FORMAT_VERSION, + community_id: uuid::Uuid::nil(), + surface: "media".into(), + generation: 2, + imported_objects: 1, + inventory_sha256: "a".repeat(64), + }; + let authority = |state, generation, imported_objects, inventory_sha256| { + buzz_db::protected_visibility::ProtectedObjectAuthority { + generation, + state, + imported_objects, + inventory_sha256, + } + }; + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Legacy, 1, None, None), + None, + ) + .unwrap(), + PreparationDisposition::Begin + ); + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 2, None, None), + None, + ) + .unwrap(), + PreparationDisposition::Resume + ); + assert_eq!( + preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 2, None, None), + Some(&sentinel), + ) + .unwrap(), + PreparationDisposition::Resume + ); + assert_eq!( + preparation_disposition( + &authority( + ProtectedObjectAuthorityState::PostgreSql, + 2, + Some(1), + Some("a".repeat(64)), + ), + Some(&sentinel), + ) + .unwrap(), + PreparationDisposition::Verify + ); + assert!(preparation_disposition( + &authority(ProtectedObjectAuthorityState::Legacy, 1, None, None), + Some(&sentinel), + ) + .is_err()); + assert!(preparation_disposition( + &authority(ProtectedObjectAuthorityState::Importing, 3, None, None), + Some(&sentinel), + ) + .is_err()); + assert!(preparation_disposition( + &authority( + ProtectedObjectAuthorityState::PostgreSql, + 2, + Some(1), + Some("a".repeat(64)), + ), + None, + ) + .is_err()); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres, Redis, MinIO, and git"] + async fn populated_git_and_media_cutover_is_validated_resumable_and_one_way() { + let (state, tenant, pool) = migration_test_state().await; + + let media_bytes = format!("migration-media-{}", uuid::Uuid::new_v4()).into_bytes(); + let media_digest = hex::encode(Sha256::digest(&media_bytes)); + let media_key = format!("{media_digest}.bin"); + state + .media_storage + .put(&media_key, &media_bytes, "application/octet-stream") + .await + .expect("legacy media blob"); + let mut media_meta = BlobMeta { + ext: "bin".into(), + mime_type: "application/octet-stream".into(), + size: media_bytes.len() as u64 + 1, + ..BlobMeta::default() + }; + state + .media_storage + .put_sidecar(&tenant, &media_digest, &media_meta) + .await + .expect("corrupt legacy sidecar fixture"); + + // Import starts durably before inventory validation. A corrupt legacy + // row must leave that checkpoint resumable and never create authority. + assert!( + prepare_postgres_authority(&state, &tenant) + .await + .expect_err("corrupt sidecar must fail") + .to_string() + .contains("size does not match"), + "corruption must be diagnosed before cutover" + ); + assert_eq!( + state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Media, + ) + .await + .expect("failed import state") + .state, + ProtectedObjectAuthorityState::Importing + ); + assert!( + read_sentinel(&state, tenant.community()) + .await + .expect("failed import sentinel probe") + .is_none(), + "corrupt inventory must not create a cutover sentinel" + ); + + media_meta.size = media_bytes.len() as u64; + state + .media_storage + .put_sidecar(&tenant, &media_digest, &media_meta) + .await + .expect("repair sidecar fixture"); + prepare_postgres_authority(&state, &tenant) + .await + .expect("resume validated media import"); + + let owner = hex::encode(Sha256::digest(uuid::Uuid::new_v4().as_bytes())); + let repo_id = format!("migration-{}", uuid::Uuid::new_v4().simple()); + state + .db + .reserve_repo_name(tenant.community(), &repo_id, &owner) + .await + .expect("legacy repo reservation"); + let manifest = Manifest { + version: MANIFEST_VERSION, + head: "refs/heads/main".into(), + refs: BTreeMap::new(), + packs: Vec::new(), + parent: None, + }; + manifest.validate().expect("valid empty manifest"); + let manifest_key = state + .git_store + .put_manifest(&manifest.canonical_bytes().expect("manifest bytes")) + .await + .expect("legacy manifest"); + let manifest_digest = manifest_key + .strip_prefix("manifests/") + .expect("manifest digest") + .to_owned(); + state + .git_store + .put_pointer( + &pointer_key(tenant.community(), &owner, &repo_id), + manifest_digest.as_bytes(), + Precond::IfNoneMatchStar, + ) + .await + .expect("legacy pointer"); + + // A transaction-owned announcement with no legacy pointer remains a + // valid first-push reservation after cutover. + let unpublished_repo = format!("unpublished-{}", uuid::Uuid::new_v4().simple()); + let announcement_id = hex::encode(Sha256::digest(uuid::Uuid::new_v4().as_bytes())); + let owner_bytes = hex::decode(&owner).expect("owner bytes"); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, d_tag) \ + VALUES ($1, $2, $3, clock_timestamp(), 30617, $4, '', $5, $6)", + ) + .bind(tenant.community().as_uuid()) + .bind(hex::decode(&announcement_id).expect("announcement bytes")) + .bind(&owner_bytes) + .bind(serde_json::json!([["d", unpublished_repo.clone()]])) + .bind(vec![0_u8; 64]) + .bind(&unpublished_repo) + .execute(&pool) + .await + .expect("protected repository announcement"); + sqlx::query( + "INSERT INTO git_repo_names \ + (community_id, repo_id, owner_pubkey, publication_origin) \ + VALUES ($1, $2, $3, 'protected_unpublished')", + ) + .bind(tenant.community().as_uuid()) + .bind(&unpublished_repo) + .bind(&owner) + .execute(&pool) + .await + .expect("protected first-push reservation"); + + crate::api::git::migration::prepare_postgres_authority(&state, &tenant) + .await + .expect("validated Git import"); + + let media_publication = state + .db + .media_publication(tenant.community(), &media_digest) + .await + .expect("media publication query") + .expect("imported media publication"); + assert_eq!(media_publication.object_key, media_key); + assert_eq!(media_publication.object_size, media_bytes.len() as u64); + let git_publication = state + .db + .git_publication(tenant.community(), &repo_id, &owner) + .await + .expect("Git publication query") + .expect("imported Git publication"); + assert_eq!(git_publication.manifest_sha256, manifest_digest); + assert!(state + .db + .git_publication(tenant.community(), &unpublished_repo, &owner) + .await + .expect("first-push publication query") + .is_none()); + + // The first protected push commits only the PostgreSQL publication. + // It must remain readable through the immutable manifest after a + // migration verification restart, without reviving a legacy pointer. + let policy = buzz_db::protected_publication::GitPolicyCommitFence { + announcement_id, + channel_id: None, + grant: buzz_db::protected_publication::GitPolicyGrant::RepoOwner, + }; + let mut git_transaction = state.db.begin_transaction().await.expect("Git transaction"); + let first_push = buzz_db::protected_publication::compare_and_publish_git( + &mut git_transaction, + buzz_db::protected_publication::GitPublicationRequest { + community_id: tenant.community(), + repo_id: &unpublished_repo, + owner_pubkey: &owner, + expected: None, + manifest_sha256: &manifest_digest, + pusher_pubkey: &owner_bytes, + policy: &policy, + }, + ) + .await + .expect("first protected push"); + git_transaction.commit().await.expect("commit first push"); + assert!(matches!( + first_push, + buzz_db::protected_publication::GitPublicationOutcome::Published( + buzz_db::protected_publication::GitPublication { + publication_version: 1, + .. + } + ) + )); + let first_push_publication = state + .db + .git_publication(tenant.community(), &unpublished_repo, &owner) + .await + .expect("first-push read") + .expect("first push is PostgreSQL-authoritative"); + assert_eq!(first_push_publication.manifest_sha256, manifest_digest); + assert_eq!( + crate::api::git::hydrate::load_manifest_by_digest( + &state.git_store, + &first_push_publication.manifest_sha256, + ) + .await + .expect("published manifest read"), + manifest + ); + crate::api::git::hydrate::hydrate_for_published_read( + &state.git_store, + &first_push_publication.manifest_sha256, + crate::api::git::hydrate::HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }, + ) + .await + .expect("first protected publication hydrates for read"); + assert!(state + .git_store + .get_pointer(&pointer_key(tenant.community(), &owner, &unpublished_repo,)) + .await + .expect("legacy first-push pointer probe") + .is_none()); + + // A protected media publication likewise uses PostgreSQL for + // visibility while the immutable object remains in object storage. + let protected_media_bytes = + format!("protected-media-{}", uuid::Uuid::new_v4()).into_bytes(); + let protected_media_digest = hex::encode(Sha256::digest(&protected_media_bytes)); + let protected_media_key = format!("{protected_media_digest}.bin"); + state + .media_storage + .put( + &protected_media_key, + &protected_media_bytes, + "application/octet-stream", + ) + .await + .expect("protected media blob"); + let sidecar_key = + buzz_media::MediaStorage::ctx_sidecar_key(&tenant, &protected_media_digest); + assert!(!state + .media_storage + .head(&sidecar_key) + .await + .expect("protected sidecar probe")); + let protected_media = buzz_db::protected_publication::MediaPublication { + sha256: protected_media_digest.clone(), + object_key: protected_media_key.clone(), + extension: "bin".into(), + mime_type: "application/octet-stream".into(), + object_size: protected_media_bytes.len() as u64, + metadata: serde_json::json!({"synthetic": true}), + thumbnail_key: None, + publication_version: 1, + }; + let mut media_transaction = state + .db + .begin_transaction() + .await + .expect("media transaction"); + buzz_db::protected_publication::publish_media( + &mut media_transaction, + tenant.community(), + &protected_media, + ) + .await + .expect("protected media publication"); + media_transaction + .commit() + .await + .expect("commit media publication"); + assert_eq!( + state + .db + .media_publication(tenant.community(), &protected_media_digest) + .await + .expect("protected media read") + .expect("protected media is PostgreSQL-authoritative") + .object_key, + protected_media_key + ); + assert_eq!( + state + .media_storage + .get(&protected_media_key) + .await + .expect("protected object read"), + protected_media_bytes + ); + let (resolved_mime, resolved_key) = crate::api::media::resolve_visible_media( + &state, + &tenant, + &format!("{protected_media_digest}.bin"), + &None, + ) + .await + .expect("non-Enforce mode retains PostgreSQL-authoritative visibility"); + assert_eq!(resolved_mime, "application/octet-stream"); + assert_eq!(resolved_key, protected_media_key); + assert!(!state + .media_storage + .head(&sidecar_key) + .await + .expect("protected sidecar recheck")); + + // Completed imports are exact idempotent verification passes. + prepare_postgres_authority(&state, &tenant) + .await + .expect("media verification retry"); + crate::api::git::migration::prepare_postgres_authority(&state, &tenant) + .await + .expect("Git verification retry"); + require_reconciled_authority(&state, &tenant) + .await + .expect("media reconciled"); + crate::api::git::migration::require_reconciled_authority(&state, &tenant) + .await + .expect("Git reconciled"); + assert!(require_legacy_sentinel_absent(&state, &tenant) + .await + .is_err()); + assert!( + crate::api::git::migration::require_legacy_sentinel_absent(&state, &tenant) + .await + .is_err() + ); + + // Simulate restoring only the database to a pre-cutover state. The + // immutable object-store sentinel must force domain denial rather than + // reviving the legacy lane or creating split-brain visibility. + sqlx::query( + "UPDATE protected_object_authority \ + SET state = 'legacy', generation = 1, imported_objects = 0, \ + inventory_sha256 = NULL, started_at = NULL, completed_at = NULL \ + WHERE community_id = $1 AND surface IN ('git', 'media')", + ) + .bind(tenant.community().as_uuid()) + .execute(&pool) + .await + .expect("simulate database restore"); + assert!(require_reconciled_authority(&state, &tenant).await.is_err()); + assert!( + crate::api::git::migration::require_reconciled_authority(&state, &tenant) + .await + .is_err() + ); + assert!(require_legacy_sentinel_absent(&state, &tenant) + .await + .is_err()); + assert!( + crate::api::git::migration::require_legacy_sentinel_absent(&state, &tenant) + .await + .is_err() + ); + } +} diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index c47e8df376..04a428e755 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; +use std::{future::poll_fn, pin::Pin}; use axum::extract::ws::{Message as WsMessage, WebSocket}; use axum::http::{HeaderMap, StatusCode}; @@ -22,22 +23,32 @@ use axum::{ response::IntoResponse, }; use bytes::Bytes; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{Sink, SinkExt, StreamExt}; use nostr::{EventBuilder, Kind, Tag}; use serde::Deserialize; +use sha2::{Digest, Sha256}; use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use buzz_auth::generate_challenge; -use buzz_core::tenant::TenantContext; +use buzz_auth::{ + generate_challenge, AuthTransport, AuthorizationCapability, VerifiedDelegationOutput, + VerifiedEvidenceAdapter, +}; +use buzz_core::{tenant::TenantContext, CommunityId}; use buzz_db::channel::MemberRole; use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; -use crate::audio::room::PeerCtrl; +use crate::audio::room::{ + AudioRoomManager, PeerCtrl, ProtectedDeadlineSchedule, ProtectedPeerEffects, + ProtectedPeerEpoch, Room, RoomOwnerEpoch, +}; +use crate::authorization_runtime::transport::{ + authorize_session_if_configured, ProtectedAuthorization, +}; use crate::state::{run_registered_community_connection, AppState}; /// Maximum binary frame size: 4 KB is generous for a single Opus packet. @@ -60,6 +71,62 @@ const MAX_MISSED_PONGS: u8 = 3; /// Auth timeout. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); +/// Enforce-only exact room claim. Every return path after room acquisition +/// passes through this drop guard, so cleanup cannot retire a replacement room +/// or release a replacement owner generation. +struct ProtectedRoomRetirement { + rooms: Arc, + community_id: CommunityId, + channel_id: Uuid, + room: Arc, + owner_epoch: Option, + owners: Option>, + local_runtime_id: Option, +} + +impl ProtectedRoomRetirement { + fn retire_if_empty(&self) -> bool { + if let Some(epoch) = self.owner_epoch { + self.rooms.retire_exact_owner_if_empty( + self.community_id, + self.channel_id, + &self.room, + epoch, + || { + if self.local_runtime_id == Some(epoch.owner_runtime_id) { + if let Some(owners) = &self.owners { + owners.release(self.channel_id, epoch.generation); + } + } + }, + ) + } else { + false + } + } +} + +impl Drop for ProtectedRoomRetirement { + fn drop(&mut self) { + self.retire_if_empty(); + } +} + +/// Exact fallback cleanup for every success, error, cancellation, timeout, +/// and early-return path after a protected peer activates. +struct ProtectedActivePeerGuard { + room: std::sync::Weak, + epoch: ProtectedPeerEpoch, +} + +impl Drop for ProtectedActivePeerGuard { + fn drop(&mut self) { + if let Some(room) = self.room.upgrade() { + room.remove_protected_epoch(self.epoch); + } + } +} + /// WebSocket upgrade handler for `/huddle/:channel_id/audio`. pub async fn ws_audio_handler( State(state): State>, @@ -89,7 +156,7 @@ pub async fn ws_audio_handler( let permit = match acquire_audio_connection_permit(&state.conn_semaphore) { Some(permit) => permit, None => { - warn!(channel_id = %channel_id, "Connection limit reached, rejecting audio WebSocket"); + warn!("Connection limit reached, rejecting audio WebSocket"); return ( StatusCode::SERVICE_UNAVAILABLE, "relay: connection limit reached", @@ -97,10 +164,17 @@ pub async fn ws_audio_handler( .into_response(); } }; - let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - &headers, - &state.config.corporate_identity, - ); + let corporate_identity_assertion = + match crate::corporate_identity::identity_assertion_from_headers( + &state, + tenant.community(), + &headers, + ) { + Ok(assertion) => assertion, + Err(error) => { + return (error.status_code(), error.public_message()).into_response(); + } + }; // Keep the parser boundary at the largest message this route accepts. The // checks in the receive loop still distinguish text from binary policy, but @@ -112,7 +186,7 @@ pub async fn ws_audio_handler( tenant, channel_id, permit, - corporate_identity_jwt, + corporate_identity_assertion, ) }) } @@ -151,64 +225,23 @@ fn default_protocol_version() -> u8 { 1 } -/// Remove a denied private admission and release only the exact owner lease -/// that this connection acquired. The room is sealed while it is still the -/// manager-visible instance, so a remote registration that already holds its -/// `Arc` cannot enter between the peer removal and the Redis release. -async fn cleanup_failed_private_audio_admission( - state: &Arc, - tenant: &TenantContext, - channel_id: Uuid, - room: &Arc, - peer_id: Uuid, - acquired_lease: &mut Option, -) { - let directory = state - .mesh() - .map(|mesh| &mesh.directory as &dyn crate::audio::join::HuddleDirectory); - match crate::audio::join::cleanup_failed_admission_lease( - directory, - acquired_lease, - &state.audio_rooms, - tenant.community(), - channel_id, - room, - peer_id, - ) - .await - { - Ok(Some(crate::audio::join::HuddleReleaseOutcome::Released)) | Ok(None) => {} - Ok(Some(crate::audio::join::HuddleReleaseOutcome::NotOwner)) => { - debug!( - channel_id = %channel_id, - "failed audio admission lease already moved; stale cleanup left current owner intact" - ); - } - Err(e) => { - warn!( - channel_id = %channel_id, - "failed audio admission could not release huddle owner lease: {e}" - ); - } - } -} - async fn handle_audio_connection( socket: WebSocket, state: Arc, tenant: TenantContext, channel_id: Uuid, _permit: OwnedSemaphorePermit, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let cancel = CancellationToken::new(); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); let run_state = Arc::clone(&state); + let session_id = Uuid::new_v4(); run_registered_community_connection( ®istry, - Uuid::new_v4(), + session_id, community_id, cancel.clone(), move || async move { check_state.db.is_community_active(community_id).await }, @@ -218,8 +251,9 @@ async fn handle_audio_connection( run_state, tenant, channel_id, + session_id, cancel, - corporate_identity_jwt, + corporate_identity_assertion, ) }, ) @@ -231,8 +265,9 @@ async fn handle_active_audio_connection( state: Arc, tenant: TenantContext, channel_id: Uuid, + session_id: Uuid, cancel: CancellationToken, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let (mut ws_send, mut ws_recv) = socket.split(); @@ -254,7 +289,7 @@ async fn handle_active_audio_connection( while let Some(Ok(msg)) = ws_recv.next().await { if let WsMessage::Text(text) = msg { if text.len() > MAX_TEXT_FRAME_BYTES { - warn!(channel_id = %channel_id, "auth text frame too large — dropping"); + warn!("auth text frame too large — dropping"); continue; } if let Ok(auth) = serde_json::from_str::(&text) { @@ -271,13 +306,14 @@ async fn handle_active_audio_connection( let auth_msg = match auth_result { Ok(Some(a)) => a, _ => { - debug!(channel_id = %channel_id, "audio auth timeout or disconnect"); + debug!("audio auth timeout or disconnect"); return; } }; // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let verified_event = auth_msg.event.clone(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); let auth_ctx = match state @@ -287,7 +323,7 @@ async fn handle_active_audio_connection( { Ok(ctx) => ctx, Err(e) => { - warn!(channel_id = %channel_id, "audio auth failed: {e}"); + warn!("audio auth failed: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type":"error","message":"auth failed"}) @@ -304,26 +340,34 @@ async fn handle_active_audio_connection( let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; + let identity_lane = + crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()); let identity_proof = match crate::corporate_identity::verify_corporate_identity( &state, tenant.community(), pubkey, - corporate_identity_jwt.as_deref(), + corporate_identity_assertion.as_ref(), auth_tag_json.as_deref(), ) .await { - Ok(proof) => proof, + Ok(proof) => Some(proof), Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity denied"); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) - .to_string() - .into(), - )) - .await; - return; + warn!(error = ?e, "audio: corporate identity denied"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } } }; @@ -336,7 +380,7 @@ async fn handle_active_audio_connection( .await .is_err() { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied"); + warn!("audio: relay membership denied"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type": "error", "message": "restricted: not a relay member"}) @@ -347,67 +391,273 @@ async fn handle_active_audio_connection( return; } + let transport_delegation = crate::corporate_identity::verify_unconditional_nip_oa_owner( + pubkey, + auth_tag_json.as_deref(), + ) + .map(|owner| VerifiedDelegationOutput::from_workspace_verifier(owner, pubkey, None, true)); + let verified_proof = match VerifiedEvidenceAdapter::new().verify_nip42( + tenant.community(), + AuthTransport::Audio, + &verified_event, + &challenge, + &relay_url, + transport_delegation, + ) { + Ok(proof) => Arc::new(proof), + Err(error) => { + warn!(error = %error, "audio: sealed NIP-42 evidence denied"); + return; + } + }; + let proof_fingerprint = verified_proof.operation_binding().fingerprint(); + let mut correlation = [0_u8; 16]; + correlation.copy_from_slice(&proof_fingerprint[..16]); + correlation[6] = (correlation[6] & 0x0f) | 0x50; + correlation[8] = (correlation[8] & 0x3f) | 0x80; + let verified_assertion = match identity_proof.as_ref() { + Some(proof) => match crate::corporate_identity::current_verified_assertion_for_proof( + &state, + proof, + tenant.community(), + AuthTransport::Audio, + ) { + Ok(assertion) => assertion.map(Arc::new), + Err(error) => { + warn!(error = %error, "audio federated evidence denied"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + return; + } + } + }, + None => None, + }; + let protected_authority = match authorize_session_if_configured( + &state, + Arc::clone(&verified_proof), + verified_assertion, + AuthorizationCapability::AudioJoin, + Uuid::from_bytes(correlation), + "audio.join", + session_id, + cancel.clone(), + ) + .await + { + Ok(authority) => Arc::new(authority), + Err(error) => { + warn!(error = %error, "audio: protected authorization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type":"error","message":"audio authorization denied"}) + .to_string() + .into(), + )) + .await; + return; + } + }; + if protected_authority.revalidate().is_err() { + return; + } + // ── Step 3: membership check / auto-add ─────────────────────────────────── - let (parent_id_for_event, auto_add_member_by) = match ensure_membership( + let membership = match ensure_membership( &state, &tenant, channel_id, &pubkey_bytes, parent_channel_id, + protected_authority.is_enforcing(), ) .await { - Ok(parent_id) => parent_id, + Ok(membership) => membership, Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}"); - let _ = ws_send - .send(WsMessage::Text( + warn!("audio membership denied: {e}"); + let _ = send_protected_ws( + &mut ws_send, + WsMessage::Text( serde_json::json!({"type":"error","message":"not a member"}) .to_string() .into(), - )) - .await; + ), + protected_authority.as_ref(), + ) + .await; return; } }; + let parent_id_for_event = membership.lifecycle_parent_id(); - // Existing members and open channels retain the established identity path. - // Private-huddle auto-add is deferred until room admission succeeds, then - // membership and direct identity binding commit in one database transaction. - let deferred_private_admission = if let Some(added_by) = auto_add_member_by { - Some((added_by, identity_proof)) + if protected_authority.revalidate().is_err() { + return; + } + // Preserve the atomic private-huddle enrollment boundary. + // Enforce never reaches this legacy auto-add path; observation lanes may + // preserve legacy membership behavior but cannot mutate identity state. + let deferred_private_admission = if let AudioMembership::LegacyAutoAdd { added_by, .. } = + &membership + { + Some(( + added_by.clone(), + if identity_lane == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + identity_proof + } else { + None + }, + )) } else { - let identity_decision = match crate::corporate_identity::finalize_corporate_identity( + if identity_lane == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy { + if let Some(identity_proof) = identity_proof { + let identity_decision = + match crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(error = ?e, "audio: corporate identity finalization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } + }; + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + tenant.community(), + pubkey, + identity_decision, + cancel.clone(), + ); + } + } + None + }; + let protected_admission_id = if protected_authority.is_enforcing() { + match commit_existing_member_audio_admission( &state, - tenant.community(), - pubkey, - identity_proof, + &tenant, + channel_id, + &pubkey, + &verified_proof, + session_id, + protected_authority.as_ref(), ) .await { - Ok(decision) => decision, - Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) - .to_string() - .into(), - )) - .await; + Ok(admission_id) => Some(admission_id), + Err(_) => { + let _ = send_protected_ws( + &mut ws_send, + WsMessage::Text( + serde_json::json!({ + "type":"error", + "message":"audio authorization denied" + }) + .to_string() + .into(), + ), + protected_authority.as_ref(), + ) + .await; + cancel.cancel(); return; } - }; - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - tenant.community(), - pubkey, - identity_decision, - cancel.clone(), - ); + } + } else { None }; - + let mut durable_audio_admission = match protected_admission_id { + Some(admission_id) => { + let Some(guard) = DurableAudioAdmissionGuard::new( + &state, + tenant.community(), + admission_id, + session_id, + ) else { + cancel.cancel(); + return; + }; + Some(guard) + } + None => None, + }; + let protected_deadline_schedule = if protected_authority.is_enforcing() { + // Anchor monotonic time before consulting the injected authority clock; + // clock sampling latency must consume, never extend, the lease. + let monotonic_anchor = tokio::time::Instant::now(); + match ( + protected_authority.expires_at(), + protected_authority.expiry_delay(), + ) { + (Some(deadline), Ok(Some(delay))) => { + match ProtectedDeadlineSchedule::new_anchored( + deadline, + monotonic_anchor, + Some(delay), + ) { + Ok(schedule) => Some(schedule), + Err(error) => { + warn!(?error, "audio: protected deadline unavailable"); + cancel.cancel(); + return; + } + } + } + _ => { + warn!("audio: protected deadline unavailable"); + cancel.cancel(); + return; + } + } + } else { + None + }; + let protected_expiry_task = protected_deadline_schedule.map(|schedule| { + let expiry_cancel = cancel.clone(); + tokio::spawn(async move { + tokio::select! { + _ = expiry_cancel.cancelled() => {} + _ = tokio::time::sleep_until(schedule.wake_at()) => { + expiry_cancel.cancel(); + } + } + }) + }); + let protected_revalidation_task = protected_authority.is_enforcing().then(|| { + let authority = Arc::clone(&protected_authority); + let revalidation_cancel = cancel.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(100)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = revalidation_cancel.cancelled() => break, + _ = interval.tick() => { + if authority.revalidate().is_err() { + revalidation_cancel.cancel(); + break; + } + } + } + } + }) + }); // Huddle cross-pod routing (mesh) OR single-pod guardrail. // // When the mesh is live (`state.mesh()` is `Some`), a huddle can span pods: @@ -426,6 +676,10 @@ async fn handle_active_audio_connection( // 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; + if protected_authority.revalidate().is_err() { + cancel.cancel(); + return; + } match state.mesh() { Some(mesh) => { if mesh.owners.is_draining() { @@ -456,11 +710,7 @@ async fn handle_active_audio_connection( pending_remote = Some(resolved.outcome); } Err(e) => { - warn!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - "huddle join rejected by fence: {e}" - ); + warn!("huddle join rejected by fence: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({ @@ -478,11 +728,7 @@ async fn handle_active_audio_connection( } None => { if !state.config.huddle_audio_available { - debug!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - "huddle audio unavailable under horizontal scaling — rejecting join" - ); + debug!("huddle audio unavailable under horizontal scaling — rejecting join"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({ @@ -499,9 +745,73 @@ async fn handle_active_audio_connection( } } - let room = state - .audio_rooms - .get_or_create(tenant.community(), channel_id); + let room_owner_epoch = if protected_authority.is_enforcing() { + state.mesh().zip(pending_remote).map(|(mesh, outcome)| { + let fenced = outcome.fenced_header(channel_id, mesh.local_runtime_id); + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation) + }) + } else { + None + }; + // Declared before the claim so reverse drop order releases the in-flight + // claim before the exact retirement guard runs on every early return. + let protected_room_retirement; + let mut _protected_room_claim = None; + let room = match room_owner_epoch { + Some(epoch) => { + let Some(mesh) = state.mesh() else { + cancel.cancel(); + return; + }; + match state.audio_rooms.get_or_create_for_owner( + tenant.community(), + channel_id, + epoch, + |old_epoch| { + if old_epoch.owner_runtime_id == mesh.local_runtime_id { + mesh.owners.release(channel_id, old_epoch.generation); + } + }, + ) { + Ok(claim) => { + let room = claim.room(); + _protected_room_claim = Some(claim); + room + } + Err(error) => { + warn!(?error, "audio: owner room epoch unavailable"); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + } + None => state + .audio_rooms + .get_or_create(tenant.community(), channel_id), + }; + protected_room_retirement = protected_authority.is_enforcing().then(|| { + let mesh = state.mesh(); + ProtectedRoomRetirement { + rooms: Arc::clone(&state.audio_rooms), + community_id: tenant.community(), + channel_id, + room: Arc::clone(&room), + owner_epoch: room_owner_epoch, + owners: mesh.map(|mesh| Arc::clone(&mesh.owners)), + local_runtime_id: mesh.map(|mesh| mesh.local_runtime_id), + } + }); + let cleanup_room_if_empty = || { + protected_room_retirement.as_ref().map_or_else( + || { + state + .audio_rooms + .cleanup_if_empty(tenant.community(), channel_id) + }, + ProtectedRoomRetirement::retire_if_empty, + ) + }; // Re-check archived status after obtaining the room. This closes the // cross-boundary race: a joiner that passed ensure_membership before @@ -511,7 +821,7 @@ async fn handle_active_audio_connection( // handles the same-room case. 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"); + debug!("channel archived before room join"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type":"error","message":"huddle has ended"}) @@ -519,16 +829,14 @@ async fn handle_active_audio_connection( .into(), )) .await; - state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } Err(e) => { - warn!(channel_id = %channel_id, "pre-join channel check failed (fail-closed): {e}"); - state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + warn!("pre-join channel check failed (fail-closed): {e}"); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } Ok(_) => {} // Channel exists and is not archived — proceed. @@ -539,8 +847,6 @@ async fn handle_active_audio_connection( let requested_version = auth_msg.protocol_version; if requested_version == 0 || requested_version > CURRENT_PROTOCOL_VERSION { warn!( - channel_id = %channel_id, - pubkey = %pubkey_hex, requested_version, current = CURRENT_PROTOCOL_VERSION, "audio: client requested unsupported protocol version" @@ -559,12 +865,14 @@ async fn handle_active_audio_connection( .into(), )) .await; + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } // 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 pending_remote_session: Option = None; let mut remote_session: Option = None; let mut remote_stream: Option = None; let mut remote_fence: Option> = None; @@ -579,36 +887,69 @@ async fn handle_active_audio_connection( else { unreachable!("matched RemoteOwner above"); }; - match crate::audio::join::dial_remote_owner( - Arc::clone(&mesh.transport), - mesh.local_runtime_id, - owner_runtime_id, - fenced, - tenant.community(), - pubkey_hex.clone(), - requested_version, - ) - .await - { - Ok((session, stream)) => { - remote_session = Some(session); + let dial = { + let dial_future = async { + if let Some(admission_id) = protected_admission_id { + crate::audio::join::reserve_remote_owner( + Arc::clone(&mesh.transport), + mesh.local_runtime_id, + owner_runtime_id, + fenced, + crate::audio::join::RemoteReservationRequest { + community_id: tenant.community(), + admission_id, + pubkey: pubkey_hex.clone(), + protocol_version: requested_version, + }, + ) + .await + .map(|(pending, stream)| (Some(pending), None, stream)) + } else { + crate::audio::join::dial_remote_owner( + Arc::clone(&mesh.transport), + mesh.local_runtime_id, + owner_runtime_id, + fenced, + tenant.community(), + pubkey_hex.clone(), + requested_version, + ) + .await + .map(|(session, stream)| (None, Some(session), stream)) + } + }; + tokio::pin!(dial_future); + tokio::select! { + biased; + _ = cancel.cancelled() => None, + result = &mut dial_future => Some(result), + } + }; + let Some(dial) = dial else { + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + return; + }; + match dial { + Ok((pending, session, stream)) => { + pending_remote_session = pending; + remote_session = session; 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:?}"); + warn!("huddle owner rejected registration: {reason:?}"); let _ = ws_send .send(WsMessage::Text( remote_rejection_ws_error(&reason).to_string().into(), )) .await; - state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } Err(crate::audio::join::DialError::Mesh(e)) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "huddle owner registration failed: {e}"); + warn!("huddle owner registration failed: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({ @@ -619,44 +960,439 @@ async fn handle_active_audio_connection( .into(), )) .await; - state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } } } - let admission = if let Some(session) = remote_session.as_ref() { + if protected_authority.revalidate().is_err() { + if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; + } + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + if protected_admission_id.is_none() { + if let (Some(mesh), Some(outcome)) = (state.mesh(), pending_remote) { + if crate::audio::join::validate_join_before_visibility( + &mesh.directory, + tenant.community(), + channel_id, + mesh.local_runtime_id, + outcome, + ) + .await + .is_err() + { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + } + let protected_effects = + protected_admission_id.map(|_| ProtectedPeerEffects::new(cancel.clone())); + let admission = if let Some(admission_id) = protected_admission_id { + let local_reservation = if let Some(pending) = pending_remote_session.as_ref() { + room.reserve_peer_at_index( + admission_id, + pubkey_hex.clone(), + requested_version, + pending.peer_index(), + ) + } else { + room.reserve_peer(admission_id, pubkey_hex.clone(), requested_version) + }; + match local_reservation { + Ok(local_reservation) => { + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + // The PostgreSQL activation is itself a fresh transaction-owned + // authorization commit. The durable visibility transition also + // precedes every remote-owner or local room effect. It is an + // authorization for the attachment attempt, not evidence that a + // peer was published; every later failure compensates it. + if let Some(receipt) = durable_audio_admission.as_mut() { + if receipt + .activate( + &state, + protected_authority.as_ref(), + channel_id, + &pubkey.to_bytes(), + ) + .await + .is_err() + { + if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + if let Err(error) = receipt.mark_visible().await { + warn!(%error, "audio: durable peer visibility could not be witnessed"); + if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + if let Some(pending) = pending_remote_session.take() { + let fenced = pending.fenced(); + let remote_admission_id = pending.admission_id(); + let stream = remote_stream + .as_mut() + .expect("remote reservation owns its control stream"); + let activation = { + let activation_future = + crate::audio::join::activate_remote_owner(&pending, stream); + tokio::pin!(activation_future); + tokio::select! { + biased; + _ = cancel.cancelled() => None, + result = &mut activation_future => Some(result), + } + }; + let Some(activation) = activation else { + crate::audio::join::abort_remote_owner(stream, fenced, remote_admission_id) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + return; + }; + if let Err(error) = activation { + crate::audio::join::abort_remote_owner(stream, fenced, remote_admission_id) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + warn!(?error, "huddle owner activation failed"); + cancel.cancel(); + return; + } + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + crate::audio::join::abort_remote_owner(stream, fenced, remote_admission_id) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + let attachment_context = crate::audio::join::protected_audio_attachment_context( + tenant.community(), + channel_id, + remote_admission_id, + &pubkey_hex, + ); + let authority_token = + match crate::authorization_runtime::ephemeral::seal_context( + &state, + protected_authority.as_ref(), + attachment_context, + ) { + Ok(token) => token, + Err(error) => { + crate::audio::join::abort_remote_owner( + stream, + fenced, + remote_admission_id, + ) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + warn!(?error, "huddle authority sealing failed"); + cancel.cancel(); + return; + } + }; + let confirmation = { + let confirmation_future = crate::audio::join::confirm_remote_owner( + pending, + stream, + authority_token, + ); + tokio::pin!(confirmation_future); + tokio::select! { + biased; + _ = cancel.cancelled() => None, + result = &mut confirmation_future => Some(result), + } + }; + let Some(confirmation) = confirmation else { + crate::audio::join::abort_remote_owner(stream, fenced, remote_admission_id) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + return; + }; + match confirmation { + Ok(session) => { + remote_session = Some(session); + if let Some(receipt) = durable_audio_admission.as_mut() { + receipt.mark_published(); + } + } + Err(error) => { + crate::audio::join::abort_remote_owner( + stream, + fenced, + remote_admission_id, + ) + .await; + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + warn!(?error, "huddle owner confirmation failed"); + cancel.cancel(); + return; + } + } + } + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + if let (Some(mesh), Some(outcome)) = (state.mesh(), pending_remote) { + if crate::audio::join::validate_join_before_visibility( + &mesh.directory, + tenant.community(), + channel_id, + mesh.local_runtime_id, + outcome, + ) + .await + .is_err() + { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + // Redis validation above is asynchronous. Re-check the exact + // PostgreSQL-authorized attempt after it completes and before + // the synchronous visibility transition. + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + // The PostgreSQL check above follows an awaited Redis fence; + // validate the exact owner fence once more, then finish with + // a synchronous PostgreSQL/expiry check before activation. + if let (Some(mesh), Some(outcome)) = (state.mesh(), pending_remote) { + if crate::audio::join::validate_join_before_visibility( + &mesh.directory, + tenant.community(), + channel_id, + mesh.local_runtime_id, + outcome, + ) + .await + .is_err() + { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + if let Some(receipt) = durable_audio_admission.as_ref() { + if !receipt + .is_current(&state, channel_id, &pubkey.to_bytes()) + .await + { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + drop(local_reservation); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + } + let activated = match protected_deadline_schedule { + Some(schedule) => local_reservation.activate_protected_with_effects_if( + schedule, + protected_effects + .clone() + .expect("protected admission has exact effects"), + || protected_authority.revalidate().is_ok() && !cancel.is_cancelled(), + ), + // A protected V1 admission without a finite deadline cannot + // be scheduled for proactive closure and therefore fails + // closed instead of falling back to legacy activation. + None => Ok(None), + }; + match activated { + Ok(Some((activated, epoch))) => { + if remote_session.is_none() { + if let Some(receipt) = durable_audio_admission.as_mut() { + receipt.mark_published(); + } + } + if protected_authority.revalidate().is_err() || cancel.is_cancelled() { + room.remove_protected_epoch(epoch); + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + Ok((activated, Some(epoch))) + } + Ok(None) => { + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + } + } else if let Some(session) = remote_session.as_ref() { room.add_peer_at_index(pubkey_hex.clone(), requested_version, session.peer_index()) - .map(|(id, audio, ctrl)| (id, session.peer_index(), audio, ctrl)) + .map(|(id, audio, ctrl)| ((id, session.peer_index(), audio, ctrl), None)) } else { room.add_peer(pubkey_hex.clone(), requested_version) + .map(|activated| (activated, None)) }; - let (peer_id, peer_index, audio_rx, peer_ctrl_rx) = match admission { + let ((peer_id, peer_index, audio_rx, peer_ctrl_rx), protected_peer_epoch) = match admission { Ok(v) => v, Err(crate::audio::room::AdmissionError::Full) => { - warn!(channel_id = %channel_id, "audio room full (255 peers exhausted)"); + warn!("audio room full (255 peers exhausted)"); let _ = ws_send.send(WsMessage::Text(serde_json::json!({"type":"error","code":"room_full","message":"peer index space exhausted"}).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; + crate::audio::join::send_remote_close(stream, session).await; + } else if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; } + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } Err(crate::audio::room::AdmissionError::Ended) => { - debug!(channel_id = %channel_id, "room ended before admission"); + debug!("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()) - .await; + crate::audio::join::send_remote_close(stream, session).await; + } else if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; } + release_pending_huddle_lease(&state, &mut acquired_lease).await; 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"); + info!( + 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}"), @@ -664,16 +1400,28 @@ async fn handle_active_audio_connection( }).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; + crate::audio::join::send_remote_close(stream, session).await; + } else if let (Some(pending), Some(stream)) = + (pending_remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::abort_remote_owner( + stream, + pending.fenced(), + pending.admission_id(), + ) + .await; } + release_pending_huddle_lease(&state, &mut acquired_lease).await; return; } }; if let Some((added_by, identity_proof)) = deferred_private_admission { - let identity_input = - crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + debug_assert!(!protected_authority.is_enforcing()); + debug_assert!(protected_peer_epoch.is_none()); + let identity_input = identity_proof + .as_ref() + .and_then(|proof| crate::corporate_identity::binding_input_for_proof(proof, &pubkey)); let outcome = state .db .add_member_with_identity( @@ -699,7 +1447,7 @@ async fn handle_active_audio_connection( Some(buzz_db::identity_binding::BindIdentityResult::BindingRequired) } Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership auto-add failed: {e}"); + warn!("audio membership auto-add failed: {e}"); let _ = ws_send .send(WsMessage::Text( serde_json::json!({"type":"error","message":"not a member"}) @@ -710,83 +1458,101 @@ async fn handle_active_audio_connection( 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; + crate::audio::join::send_remote_close(stream, session).await; } - cleanup_failed_private_audio_admission( - &state, - &tenant, - channel_id, - &room, - peer_id, - &mut acquired_lease, - ) - .await; + room.remove_peer(peer_id); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); return; } }; - let identity_decision = - match crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, + if let Some(identity_proof) = identity_proof { + let identity_decision = + match crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + committed_binding, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(error = ?e, "audio: corporate identity finalization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + room.remove_peer(peer_id); + cleanup_room_if_empty(); + release_pending_huddle_lease(&state, &mut acquired_lease).await; + cancel.cancel(); + return; + } + }; + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), tenant.community(), pubkey, - identity_proof, - committed_binding, + identity_decision, + cancel.clone(), + ); + } + state.invalidate_membership(&tenant, channel_id, &pubkey_bytes); + } + + let _protected_active_peer = protected_peer_epoch.map(|epoch| ProtectedActivePeerGuard { + room: Arc::downgrade(&room), + epoch, + }); + if let (Some(session), Some(epoch)) = (remote_session.as_mut(), protected_peer_epoch) { + session.bind_local_epoch(epoch); + } + + // A non-owner pod accepts realtime fan-out only from the authenticated + // owner and generation established by this reliable control attachment. + let remote_media_attachment = remote_session.as_ref().and_then(|session| { + state.mesh().map(|mesh| { + mesh.audio_attachments.register_owner_fanout( + session.fenced(), + session.admission_id().unwrap_or(peer_id), + protected_authority.expires_at().unwrap_or(u64::MAX), ) - .await - { - Ok(decision) => decision, - Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) - .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; - } - cleanup_failed_private_audio_admission( - &state, - &tenant, - channel_id, - &room, - peer_id, - &mut acquired_lease, - ) - .await; - return; + }) + }); + let mut _legacy_remote_media_attachment = None; + if let Some(media_attachment) = remote_media_attachment { + if let Some(effects) = protected_effects.as_ref() { + if !effects.install_revoker(move || drop(media_attachment)) { + if let Some(epoch) = protected_peer_epoch { + room.remove_protected_epoch(epoch); + } else { + room.remove_peer(peer_id); } - }; - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - tenant.community(), - pubkey, - identity_decision, - cancel.clone(), - ); - state.invalidate_membership(&tenant, channel_id, &pubkey_bytes); + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + cancel.cancel(); + return; + } + } else { + _legacy_remote_media_attachment = Some(media_attachment); + } } - info!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - peer_index, - "audio peer joined" - ); + info!(peer_index, "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 @@ -823,7 +1589,6 @@ async fn handle_active_audio_connection( owner_generation = Some(generation); if owner_lost.is_none() { error!( - channel_id = %channel_id, "huddle owner-ready invariant violated: LocalOwner reuse with no live \ registry entry after resolve_join_owner_ready — owner peer has no \ lease-loss watcher" @@ -858,16 +1623,76 @@ async fn handle_active_audio_connection( }) .to_string(); + if protected_authority.revalidate().is_err() { + cancel.cancel(); + if let Some(epoch) = protected_peer_epoch { + room.remove_protected_epoch(epoch); + } else { + room.remove_peer(peer_id); + } + if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) { + crate::audio::join::send_remote_close(stream, session).await; + } + if cleanup_room_if_empty() { + if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { + mesh.owners.release(channel_id, generation); + } + } + return; + } if remote_session.is_some() { - if ws_send - .send(WsMessage::Text(joined_msg.into())) + let joined_sent = if let Some(epoch) = protected_peer_epoch { + let ready = poll_fn(|context| Pin::new(&mut ws_send).poll_ready(context)).await; + if ready.is_err() { + false + } else { + let publication = room.publish_protected_join_if_current( + epoch, + &pubkey_hex, + peer_index, + |_pubkey, _peer_index, _snapshot| { + Pin::new(&mut ws_send) + .start_send(WsMessage::Text(joined_msg.clone().into())) + .ok() + }, + ); + publication.is_some() && ws_send.flush().await.is_ok() + } + } else { + send_protected_ws( + &mut ws_send, + WsMessage::Text(joined_msg.clone().into()), + protected_authority.as_ref(), + ) .await - .is_err() + }; + if !joined_sent { + cancel.cancel(); + if let Some(epoch) = protected_peer_epoch { + room.remove_protected_epoch(epoch); + } else { + room.remove_peer(peer_id); + } + if let (Some(session), Some(stream)) = (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_remote_close(stream, session).await; + } + let room_emptied = cleanup_room_if_empty(); + if room_emptied { + if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { + mesh.owners.release(channel_id, generation); + } + } + return; + } + } else if let Some(epoch) = protected_peer_epoch { + if room + .broadcast_protected_join_if_current(epoch, &pubkey_hex, peer_index) + .is_none() { - room.remove_peer(peer_id); - state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + cancel.cancel(); + room.remove_protected_epoch(epoch); + cleanup_room_if_empty(); return; } } else { @@ -875,15 +1700,17 @@ async fn handle_active_audio_connection( } // ── Step 6: emit kind:48101 (PARTICIPANT_JOINED) ────────────────────────── - emit_participant_event( - &state, - &tenant, - Kind::Custom(48101), - channel_id, - parent_id_for_event, - &pubkey_hex, - ) - .await; + if !protected_authority.is_enforcing() { + emit_participant_event( + &state, + &tenant, + Kind::Custom(48101), + channel_id, + parent_id_for_event, + &pubkey_hex, + ) + .await; + } let missed_pongs = Arc::new(AtomicU8::new(0)); @@ -893,7 +1720,13 @@ async fn handle_active_audio_connection( let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, data_rx, ctrl_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + data_rx, + ctrl_rx, + send_cancel, + Arc::clone(&protected_authority), + )); let hb_cancel = cancel.clone(); let hb_missed = Arc::clone(&missed_pongs); @@ -906,6 +1739,7 @@ async fn handle_active_audio_connection( data_tx, ctrl_tx.clone(), fwd_cancel, + Arc::clone(&protected_authority), )); // Non-owner path: own the owner's `HuddleControl` stream in a reader task. @@ -934,6 +1768,10 @@ async fn handle_active_audio_connection( .expect("remote_session set whenever remote_stream is") .roster() .revision; + let admission_id = remote_session + .as_ref() + .expect("remote_session set whenever remote_stream is") + .admission_id(); let roster_ctrl_tx = ctrl_tx.clone(); tokio::spawn(async move { tokio::select! { @@ -946,7 +1784,23 @@ async fn handle_active_audio_connection( teardown_remote_huddle(cause, channel_id, &reader_cancel, &fence); } _ = reader_cancel.cancelled() => { - crate::audio::join::send_clean_close(&mut stream, fenced, &pubkey).await; + if let Some(admission_id) = admission_id { + crate::audio::join::abort_remote_owner( + &mut stream, + fenced, + admission_id, + ).await; + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(buzz_relay_mesh::MeshStreamFrame::Goodbye { + fenced, + reason: crate::audio::join::HUDDLE_SESSION_ENDED, + }), + ).await; + let _ = stream.finish(); + } else { + crate::audio::join::send_clean_close(&mut stream, fenced, &pubkey).await; + } } } }) @@ -981,7 +1835,6 @@ async fn handle_active_audio_connection( tokio::select! { _ = drain_fired => { info!( - channel_id = %channel_id, "huddle owner is draining — closing local client for rejoin" ); owner_cancel.cancel(); @@ -989,7 +1842,6 @@ async fn handle_active_audio_connection( } _ = lost_fired => { info!( - channel_id = %channel_id, "huddle owner lost its lease — closing local client for rejoin" ); owner_cancel.cancel(); @@ -1010,6 +1862,7 @@ async fn handle_active_audio_connection( ctrl_tx, Arc::clone(&missed_pongs), cancel.clone(), + Arc::clone(&protected_authority), remote_session.as_mut(), ) .await; @@ -1018,6 +1871,12 @@ async fn handle_active_audio_connection( let _ = send_task.await; let _ = heartbeat_task.await; let _ = forward_task.await; + if let Some(expiry_task) = protected_expiry_task { + let _ = expiry_task.await; + } + if let Some(revalidation_task) = protected_revalidation_task { + let _ = revalidation_task.await; + } // The reader task owns the owner control stream; joining it here guarantees // its clean-close (or teardown) completes before connection cleanup returns. if let Some(reader_task) = reader_task { @@ -1033,13 +1892,15 @@ async fn handle_active_audio_connection( // AdmissionGuard lock across index recycling AND the is_empty + ended=true // check. Ingress mirrors never archive authoritative huddle state; they // remove locally and let the owner decide room lifetime. - let should_auto_end = if remote_session.is_some() { - room.remove_peer(peer_id); - false + let (removed_peer, should_auto_end) = if let Some(epoch) = protected_peer_epoch { + (room.remove_protected_epoch(epoch), false) + } else if remote_session.is_some() { + (room.remove_peer(peer_id), false) } else { - room.remove_peer_and_check_ended(peer_id) - .map(|(_, ended)| ended) - .unwrap_or(false) + match room.remove_peer_and_check_ended(peer_id) { + Some((_, ended)) => (true, ended), + None => (false, false), + } }; let left_msg = serde_json::json!({ @@ -1048,23 +1909,33 @@ async fn handle_active_audio_connection( "peer_index": peer_index, }) .to_string(); - if remote_session.is_none() { + if remote_session.is_none() && protected_peer_epoch.is_none() && removed_peer { room.broadcast_control(left_msg); } - emit_participant_event( - &state, - &tenant, - Kind::Custom(48102), - channel_id, - parent_id_for_event, - &pubkey_hex, - ) - .await; + if !protected_authority.is_enforcing() { + emit_participant_event( + &state, + &tenant, + Kind::Custom(48102), + channel_id, + parent_id_for_event, + &pubkey_hex, + ) + .await; + } let room_emptied; - if should_auto_end { - info!(channel_id = %channel_id, "audio room empty — auto-ending huddle"); + let mut owner_release_coupled = false; + if protected_authority.is_enforcing() { + // Automatic protected-state mutation requires a separately reviewed + // system-authority model. Keep the room reusable and leave durable + // channel state unchanged. + room.clear_ended(); + room_emptied = cleanup_room_if_empty(); + owner_release_coupled = room_emptied; + } else if should_auto_end { + info!("audio room empty — auto-ending huddle"); match state .db @@ -1072,14 +1943,12 @@ async fn handle_active_audio_connection( .await { Err(e) => { - warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}"); + warn!("auto-archive failed, huddle stays alive: {e}"); room.clear_ended(); room_emptied = false; } Ok(()) => { - room_emptied = state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + room_emptied = cleanup_room_if_empty(); emit_participant_event( &state, @@ -1093,9 +1962,7 @@ async fn handle_active_audio_connection( } } } else { - room_emptied = state - .audio_rooms - .cleanup_if_empty(tenant.community(), channel_id); + room_emptied = cleanup_room_if_empty(); } // Owner path: release this room's lease when the room empties, so a new @@ -1104,17 +1971,555 @@ async fn handle_active_audio_connection( // emptied and a re-acquire installed a newer epoch in the gap, `release` // is a no-op for the stale generation and leaves the live renewer running. // Only the last leaver empties the room, so exactly one release fires. - if room_emptied { + if room_emptied && !owner_release_coupled { if let (Some(mesh), Some(generation)) = (state.mesh(), owner_generation) { mesh.owners.release(channel_id, generation); } } - info!( - channel_id = %channel_id, - pubkey = %pubkey_hex, - "audio peer left" - ); + // Local/mesh effects and roster visibility are gone before PostgreSQL + // cleanup can wait or retry. A database stall cannot retain an expired + // peer in media, control, or roster state. + if let Some(admission) = durable_audio_admission.as_mut() { + if let Err(error) = admission.request_cleanup().await { + warn!(%error, "audio: durable cleanup intent failed; drop retry scheduled"); + } + } + if let Some(admission) = durable_audio_admission.take() { + admission.finish().await; + } + + info!("audio peer left"); +} + +async fn commit_existing_member_audio_admission( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + pubkey: &nostr::PublicKey, + proof: &buzz_auth::VerifiedNostrProof, + claimant_id: Uuid, + authority: &ProtectedAuthorization, +) -> Result { + use crate::authorization_runtime::executor::{ + begin_authorized_operation, AuthorizedOperationStart, ProtectedOperationId, + }; + + let mut stable = Sha256::new(); + stable.update(b"buzz-audio-admission-operation-v1"); + stable.update(tenant.community().as_uuid().as_bytes()); + stable.update(channel_id.as_bytes()); + stable.update(pubkey.to_bytes()); + stable.update(proof.operation_binding().fingerprint()); + let stable: [u8; 32] = stable.finalize().into(); + let mut admission_bytes = [0_u8; 16]; + admission_bytes.copy_from_slice(&stable[..16]); + admission_bytes[6] = (admission_bytes[6] & 0x0f) | 0x50; + admission_bytes[8] = (admission_bytes[8] & 0x3f) | 0x80; + let admission_id = Uuid::from_bytes(admission_bytes); + let operation_id = + ProtectedOperationId::derive(tenant.community(), "audio.admission.v1", &stable)?; + let mut request = Sha256::new(); + request.update(b"buzz-audio-admission-request-v1"); + request.update(channel_id.as_bytes()); + request.update(pubkey.to_bytes()); + request.update(claimant_id.as_bytes()); + let request: [u8; 32] = request.finalize().into(); + let permit = authority + .seal_postgres_mutation(operation_id, "audio.admission.v1", request) + .map_err(|_| { + crate::authorization_runtime::executor::AuthorizationExecutionError::InvalidCommitFence + })? + .ok_or( + crate::authorization_runtime::executor::AuthorizationExecutionError::InvalidCommitFence, + )?; + match begin_authorized_operation(state, permit).await? { + AuthorizedOperationStart::Replay(payload) => { + let bytes: [u8; 16] = payload.try_into().map_err(|_| { + crate::authorization_runtime::executor::AuthorizationExecutionError::ConflictingRetry + })?; + Ok(Uuid::from_bytes(bytes)) + } + AuthorizedOperationStart::Execute(mut operation) => { + let expires_at = authority.expires_at().ok_or( + crate::authorization_runtime::executor::AuthorizationExecutionError::Expired, + )?; + buzz_db::audio_admission::admit_existing_audio_member_tx( + operation.transaction(), + tenant.community(), + admission_id, + channel_id, + &pubkey.to_bytes(), + claimant_id, + expires_at, + ) + .await + .map_err(crate::authorization_runtime::executor::AuthorizationExecutionError::Db)?; + operation.commit(admission_id.as_bytes()).await?; + Ok(admission_id) + } + } +} + +async fn activate_existing_member_audio_admission( + state: &AppState, + community_id: CommunityId, + admission_id: Uuid, + channel_id: Uuid, + pubkey: &[u8; 32], + claimant_id: Uuid, + authority: &ProtectedAuthorization, +) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + use crate::authorization_runtime::executor::{ + begin_authorized_operation, AuthorizedOperationStart, ProtectedOperationId, + }; + + let operation_id = ProtectedOperationId::derive( + community_id, + "audio.admission.activate.v1", + admission_id.as_bytes(), + )?; + let mut request = Sha256::new(); + request.update(b"buzz-audio-admission-activation-v1"); + request.update(admission_id.as_bytes()); + request.update(channel_id.as_bytes()); + request.update(pubkey); + request.update(claimant_id.as_bytes()); + let request: [u8; 32] = request.finalize().into(); + let permit = authority + .seal_postgres_mutation(operation_id, "audio.admission.activate.v1", request) + .map_err(|_| { + crate::authorization_runtime::executor::AuthorizationExecutionError::InvalidCommitFence + })? + .ok_or( + crate::authorization_runtime::executor::AuthorizationExecutionError::InvalidCommitFence, + )?; + match begin_authorized_operation(state, permit).await? { + AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != admission_id.as_bytes() + || !buzz_db::audio_admission::audio_admission_is_active( + &state.db, + community_id, + admission_id, + channel_id, + pubkey, + claimant_id, + ) + .await? + { + return Err( + crate::authorization_runtime::executor::AuthorizationExecutionError::ConflictingRetry, + ); + } + Ok(()) + } + AuthorizedOperationStart::Execute(mut operation) => { + let expires_at = authority.expires_at().ok_or( + crate::authorization_runtime::executor::AuthorizationExecutionError::Expired, + )?; + buzz_db::audio_admission::activate_audio_admission_tx( + operation.transaction(), + community_id, + admission_id, + channel_id, + pubkey, + claimant_id, + expires_at, + ) + .await?; + operation.commit(admission_id.as_bytes()).await?; + Ok(()) + } + } +} + +/// Owns durable compensation for every return path after reserve. A process +/// crash is reconciled from PostgreSQL; ordinary cancellation and errors are +/// compensated by Drop, while a durably observed attachment records completion. +struct DurableAudioAdmissionGuard { + db: buzz_db::Db, + restore: Arc, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + visibility_committed: bool, + effect_published: bool, + settled: bool, +} + +impl DurableAudioAdmissionGuard { + fn new( + state: &AppState, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + ) -> Option { + Some(Self { + db: state.db.clone(), + restore: Arc::clone(state.restore_protection()?), + community_id, + admission_id, + claimant_id, + visibility_committed: false, + effect_published: false, + settled: false, + }) + } + + async fn activate( + &mut self, + state: &AppState, + authority: &ProtectedAuthorization, + channel_id: Uuid, + pubkey: &[u8; 32], + ) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + activate_existing_member_audio_admission( + state, + self.community_id, + self.admission_id, + channel_id, + pubkey, + self.claimant_id, + authority, + ) + .await + } + + async fn mark_visible( + &mut self, + ) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + mark_durable_audio_admission_visible( + &self.db, + &self.restore, + self.community_id, + self.admission_id, + self.claimant_id, + ) + .await?; + self.visibility_committed = true; + Ok(()) + } + + fn mark_published(&mut self) { + debug_assert!( + self.visibility_committed, + "protected audio cannot publish before durable visibility" + ); + self.effect_published = self.visibility_committed; + } + + async fn request_cleanup( + &mut self, + ) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + request_durable_audio_admission_cleanup( + &self.db, + &self.restore, + self.community_id, + self.admission_id, + self.claimant_id, + ) + .await + } + + async fn finish(mut self) { + match finalize_durable_audio_admission( + &self.db, + &self.restore, + self.community_id, + self.admission_id, + self.claimant_id, + self.effect_published, + ) + .await + { + Ok(()) => self.settled = true, + Err(error) => { + warn!(%error, "audio: awaited durable admission cleanup failed; retry scheduled") + } + } + } + + async fn is_current(&self, state: &AppState, channel_id: Uuid, pubkey: &[u8; 32]) -> bool { + buzz_db::audio_admission::audio_admission_is_active( + &state.db, + self.community_id, + self.admission_id, + channel_id, + pubkey, + self.claimant_id, + ) + .await + .unwrap_or(false) + } +} + +impl Drop for DurableAudioAdmissionGuard { + fn drop(&mut self) { + if self.settled { + return; + } + let db = self.db.clone(); + let restore = Arc::clone(&self.restore); + let community_id = self.community_id; + let admission_id = self.admission_id; + let claimant_id = self.claimant_id; + let effect_published = self.effect_published; + tokio::spawn(async move { + if let Err(error) = finalize_durable_audio_admission( + &db, + &restore, + community_id, + admission_id, + claimant_id, + effect_published, + ) + .await + { + warn!(%error, "audio: durable admission cleanup retries exhausted"); + } + }); + } +} + +async fn mark_durable_audio_admission_visible( + db: &buzz_db::Db, + restore: &Arc, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, +) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + use crate::authorization_runtime::executor::{ + AuthorizationExecutionError, ProtectedOperationId, + }; + + let mut stable = Sha256::new(); + stable.update(b"buzz-audio-admission-visibility-v1"); + stable.update(admission_id.as_bytes()); + stable.update(claimant_id.as_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation = + ProtectedOperationId::derive(community_id, "audio.admission.visible.v1", &stable)?; + let mut request = Sha256::new(); + request.update(b"buzz-audio-admission-visibility-request-v1"); + request.update(stable); + let request: [u8; 32] = request.finalize().into(); + let witness = restore + .begin(community_id, operation.as_uuid(), request) + .await?; + match buzz_db::audio_admission::mark_audio_admission_visible_with_receipt( + db, + community_id, + admission_id, + claimant_id, + operation.as_uuid(), + request, + ) + .await + { + Ok(()) => witness.commit().await?, + Err(error) => { + if db + .authorization_operation_receipt_fingerprint(community_id, operation.as_uuid()) + .await? + == Some(request) + { + witness.commit().await?; + } else { + witness.abort().await?; + return Err(AuthorizationExecutionError::Db(error)); + } + } + } + Ok(()) +} + +async fn finalize_durable_audio_admission( + db: &buzz_db::Db, + restore: &Arc, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + visible: bool, +) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + let mut last_error = None; + for attempt in 0_u32..8 { + match finalize_durable_audio_admission_once( + db, + restore, + community_id, + admission_id, + claimant_id, + visible, + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + tokio::time::sleep(std::time::Duration::from_millis( + 25_u64.saturating_mul(1_u64 << attempt.min(6)), + )) + .await; + } + Err(last_error.expect("at least one durable cleanup attempt")) +} + +async fn finalize_durable_audio_admission_once( + db: &buzz_db::Db, + restore: &Arc, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, + visible: bool, +) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + use crate::authorization_runtime::executor::{ + AuthorizationExecutionError, ProtectedOperationId, + }; + + request_durable_audio_admission_cleanup(db, restore, community_id, admission_id, claimant_id) + .await?; + + let terminal = if visible { + b"finished".as_slice() + } else { + b"aborted".as_slice() + }; + let mut completion_stable = Sha256::new(); + completion_stable.update(b"buzz-audio-admission-completion-v1"); + completion_stable.update(admission_id.as_bytes()); + completion_stable.update(claimant_id.as_bytes()); + completion_stable.update(terminal); + let completion_stable: [u8; 32] = completion_stable.finalize().into(); + let completion_operation = ProtectedOperationId::derive( + community_id, + "audio.admission.complete.v1", + &completion_stable, + )?; + let mut completion_request = Sha256::new(); + completion_request.update(b"buzz-audio-admission-completion-request-v1"); + completion_request.update(completion_stable); + let completion_request: [u8; 32] = completion_request.finalize().into(); + let completion_witness = restore + .begin( + community_id, + completion_operation.as_uuid(), + completion_request, + ) + .await?; + match buzz_db::audio_admission::complete_claimed_audio_admission_with_receipt( + db, + community_id, + admission_id, + claimant_id, + visible, + (!visible).then_some("attachment_aborted"), + completion_operation.as_uuid(), + completion_request, + ) + .await + { + Ok(()) => completion_witness.commit().await?, + Err(error) => { + if db + .authorization_operation_receipt_fingerprint( + community_id, + completion_operation.as_uuid(), + ) + .await? + == Some(completion_request) + { + completion_witness.commit().await?; + } else { + completion_witness.abort().await?; + return Err(AuthorizationExecutionError::Db(error)); + } + } + } + Ok(()) +} + +async fn request_durable_audio_admission_cleanup( + db: &buzz_db::Db, + restore: &Arc, + community_id: CommunityId, + admission_id: Uuid, + claimant_id: Uuid, +) -> Result<(), crate::authorization_runtime::executor::AuthorizationExecutionError> { + use crate::authorization_runtime::executor::{ + AuthorizationExecutionError, ProtectedOperationId, + }; + + let mut cleanup_stable = Sha256::new(); + cleanup_stable.update(b"buzz-audio-admission-cleanup-request-v1"); + cleanup_stable.update(admission_id.as_bytes()); + cleanup_stable.update(claimant_id.as_bytes()); + let cleanup_stable: [u8; 32] = cleanup_stable.finalize().into(); + let cleanup_operation = ProtectedOperationId::derive( + community_id, + "audio.admission.cleanup-request.v1", + &cleanup_stable, + )?; + let mut cleanup_request = Sha256::new(); + cleanup_request.update(b"buzz-audio-admission-cleanup-request-receipt-v1"); + cleanup_request.update(cleanup_stable); + let cleanup_request: [u8; 32] = cleanup_request.finalize().into(); + let cleanup_witness = restore + .begin(community_id, cleanup_operation.as_uuid(), cleanup_request) + .await?; + match buzz_db::audio_admission::request_audio_admission_cleanup_with_receipt( + db, + community_id, + admission_id, + claimant_id, + cleanup_operation.as_uuid(), + cleanup_request, + ) + .await + { + Ok(()) => cleanup_witness.commit().await?, + Err(error) => { + if db + .authorization_operation_receipt_fingerprint( + community_id, + cleanup_operation.as_uuid(), + ) + .await? + == Some(cleanup_request) + { + cleanup_witness.commit().await?; + } else { + cleanup_witness.abort().await?; + return Err(AuthorizationExecutionError::Db(error)); + } + } + } + + Ok(()) +} + +async fn release_pending_huddle_lease( + state: &AppState, + lease: &mut Option, +) { + let (Some(mesh), Some(owned_lease)) = (state.mesh(), lease.take()) else { + return; + }; + let mut last_error = None; + for attempt in 0_u32..5 { + match crate::audio::join::HuddleDirectory::release(&mesh.directory, &owned_lease).await { + Ok(_) => return, + Err(error) => { + last_error = Some(error); + tokio::time::sleep(std::time::Duration::from_millis( + 25_u64.saturating_mul(1_u64 << attempt), + )) + .await; + } + } + } + // The lease itself remains bounded by Redis TTL, but a failed explicit + // compensation must never disappear silently. + warn!(error = ?last_error, "audio: failed to release pending owner lease after retries"); } /// React to a non-owner huddle teardown signal read off the owner's control @@ -1134,7 +2539,6 @@ fn teardown_remote_huddle( fence: &crate::audio::mesh::GenerationFloor, ) { info!( - channel_id = %channel_id, ?cause, "owner tore down cross-pod huddle session — closing client for rejoin" ); @@ -1185,6 +2589,7 @@ async fn recv_loop( ctrl_tx: mpsc::Sender, missed_pongs: Arc, cancel: CancellationToken, + protected_authority: Arc, mut remote_session: Option<&mut crate::audio::join::RemoteHuddleSession>, ) { use crate::audio::wire::{FrameHeader, V2_HEADER_LEN}; @@ -1194,6 +2599,10 @@ async fn recv_loop( biased; _ = cancel.cancelled() => break, msg = ws_recv.next() => { + if protected_authority.revalidate().is_err() { + cancel.cancel(); + break; + } match msg { Some(Ok(WsMessage::Binary(data))) => { if data.len() > MAX_AUDIO_FRAME_BYTES { @@ -1252,7 +2661,7 @@ async fn recv_loop( // to every participant, including our co-located peers. // Owner/local path fans out through the local room. match remote_session.as_deref_mut() { - Some(session) => session.forward_media(&data), + Some(session) => session.forward_media(&room, &data), None => room.broadcast_frame(peer_id, data), } } @@ -1285,6 +2694,30 @@ async fn recv_loop( } } +/// Wait for sink readiness, then revalidate immediately before `start_send`. +async fn send_protected_ws( + sink: &mut S, + message: WsMessage, + protected_authority: &dyn crate::connection::OutboundReleaseFence, +) -> bool +where + S: futures_util::Sink + Unpin, +{ + if std::future::poll_fn(|cx| std::pin::Pin::new(&mut *sink).poll_ready(cx)) + .await + .is_err() + { + return false; + } + if !protected_authority.release() { + return false; + } + if std::pin::Pin::new(&mut *sink).start_send(message).is_err() { + return false; + } + futures_util::SinkExt::flush(sink).await.is_ok() +} + /// Outbound send loop with control-frame priority (matches connection.rs pattern). /// /// Control frames (Ping, Pong, Close, control JSON) are drained first on every @@ -1294,11 +2727,13 @@ async fn send_loop( mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, cancel: CancellationToken, + protected_authority: Arc, ) { loop { // Priority: drain all pending control frames before data. while let Ok(ctrl_msg) = ctrl_rx.try_recv() { - if ws_send.send(ctrl_msg).await.is_err() { + if !send_protected_ws(&mut ws_send, ctrl_msg, protected_authority.as_ref()).await { + cancel.cancel(); return; } } @@ -1310,10 +2745,16 @@ async fn send_loop( break; } Some(ctrl_msg) = ctrl_rx.recv() => { - if ws_send.send(ctrl_msg).await.is_err() { break; } + if !send_protected_ws(&mut ws_send, ctrl_msg, protected_authority.as_ref()).await { + cancel.cancel(); + break; + } } Some(msg) = data_rx.recv() => { - if ws_send.send(msg).await.is_err() { break; } + if !send_protected_ws(&mut ws_send, msg, protected_authority.as_ref()).await { + cancel.cancel(); + break; + } } } } @@ -1331,6 +2772,7 @@ async fn audio_forward_loop( data_tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, + protected_authority: Arc, ) { loop { tokio::select! { @@ -1338,19 +2780,33 @@ async fn audio_forward_loop( _ = cancel.cancelled() => break, // Control messages get priority over audio in the select. msg = peer_ctrl_rx.recv() => { + if protected_authority.revalidate().is_err() { + cancel.cancel(); + break; + } match msg { Some(PeerCtrl::Json(json)) => { let _ = ctrl_tx.try_send(WsMessage::Text(json.into())); } - Some(PeerCtrl::Close) | None => break, + Some(PeerCtrl::Close) | None => { + cancel.cancel(); + break; + } } } frame = audio_rx.recv() => { + if protected_authority.revalidate().is_err() { + cancel.cancel(); + break; + } match frame { Some(bytes) => { let _ = data_tx.try_send(WsMessage::Binary(bytes)); } - None => break, + None => { + cancel.cancel(); + break; + } } } } @@ -1389,7 +2845,8 @@ async fn ensure_membership( channel_id: Uuid, pubkey_bytes: &[u8], parent_channel_id: Option, -) -> Result<(Uuid, Option>), String> { + enforcing: bool, +) -> Result { // 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 @@ -1426,17 +2883,32 @@ async fn ensure_membership( }; // Fast path: already a member. - let is_member = state - .is_member_cached(tenant.community(), channel_id, pubkey_bytes) - .await - .map_err(|e| format!("db error: {e}"))?; + let is_member = if enforcing { + state + .db + .is_member(tenant.community(), channel_id, pubkey_bytes) + .await + } else { + state + .is_member_cached(tenant.community(), channel_id, pubkey_bytes) + .await + } + .map_err(|e| format!("db error: {e}"))?; if is_member { - return Ok((lifecycle_parent_id, None)); + return Ok(AudioMembership::ExistingMember { + lifecycle_parent_id, + }); + } + + if enforcing { + return Err("not a member".into()); } if channel.visibility == "open" { - return Ok((lifecycle_parent_id, None)); + return Ok(AudioMembership::LegacyOpenGuest { + lifecycle_parent_id, + }); } // Auto-add path: private ephemeral channel + caller is member of parent. @@ -1447,13 +2919,46 @@ async fn ensure_membership( .map_err(|e| format!("db error: {e}"))?; if parent_member { - return Ok((lifecycle_parent_id, Some(channel.created_by))); + return Ok(AudioMembership::LegacyAutoAdd { + lifecycle_parent_id, + added_by: channel.created_by, + }); } } Err("not a member".into()) } +enum AudioMembership { + ExistingMember { + lifecycle_parent_id: Uuid, + }, + LegacyOpenGuest { + lifecycle_parent_id: Uuid, + }, + LegacyAutoAdd { + lifecycle_parent_id: Uuid, + added_by: Vec, + }, +} + +impl AudioMembership { + const fn lifecycle_parent_id(&self) -> Uuid { + match self { + Self::ExistingMember { + lifecycle_parent_id, + } + | Self::LegacyOpenGuest { + lifecycle_parent_id, + } + | Self::LegacyAutoAdd { + lifecycle_parent_id, + .. + } => *lifecycle_parent_id, + } + } +} + async fn emit_participant_event( state: &AppState, tenant: &TenantContext, @@ -1491,8 +2996,6 @@ async fn emit_participant_event( } }; - let event_id_hex = event.id.to_hex(); - // 1. Persist to DB so late-joining clients can reconstruct huddle state // from historical queries. Without this, lifecycle events only exist // for the duration of the Redis pub/sub delivery and are lost forever. @@ -1505,11 +3008,7 @@ async fn emit_participant_event( Ok((_, false)) => { // Duplicate — already persisted (e.g. concurrent emit). Skip fan-out // to avoid double-delivery, matching the side_effects.rs pattern. - debug!( - event_id = %event_id_hex, - channel_id = %parent_channel_id, - "audio lifecycle event already persisted — skipping fan-out" - ); + debug!("audio lifecycle event already persisted — skipping fan-out"); return; } Err(e) => { @@ -1518,8 +3017,6 @@ async fn emit_participant_event( // would leave connected clients stale. Late joiners will have an // inconsistent view until the next huddle lifecycle event lands. warn!( - event_id = %event_id_hex, - channel_id = %parent_channel_id, kind = %event.kind.as_u16(), "audio: failed to persist lifecycle event: {e}" ); @@ -1547,16 +3044,13 @@ async fn emit_participant_event( state .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 lifecycle event: {e}" - ); + warn!("audio: failed to publish lifecycle event: {e}"); } } #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use axum::{routing::get, Router}; @@ -1567,6 +3061,143 @@ mod tests { use super::*; + struct ScriptedAudioFence(AtomicBool); + + impl crate::connection::OutboundReleaseFence for ScriptedAudioFence { + fn release(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + struct AudioReadinessBarrierSink { + ready: Arc, + polled: Arc, + waker: Arc>>, + sent: Arc, + } + + impl futures_util::Sink for AudioReadinessBarrierSink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.ready.load(Ordering::SeqCst) { + std::task::Poll::Ready(Ok(())) + } else { + *self.waker.lock().expect("audio barrier waker poisoned") = + Some(cx.waker().clone()); + self.polled.notify_one(); + std::task::Poll::Pending + } + } + + fn start_send(self: std::pin::Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + self.sent.store(true, Ordering::SeqCst); + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.poll_flush(cx) + } + } + + #[tokio::test] + async fn audio_frame_revalidates_after_sink_readiness() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let sent = Arc::new(AtomicBool::new(false)); + let fence = Arc::new(ScriptedAudioFence(AtomicBool::new(true))); + let sink = AudioReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + sent: Arc::clone(&sent), + }; + let task_fence = Arc::clone(&fence); + let task = tokio::spawn(async move { + let mut sink = sink; + send_protected_ws( + &mut sink, + WsMessage::Text("protected".into()), + task_fence.as_ref(), + ) + .await + }); + + polled.notified().await; + fence.0.store(false, Ordering::SeqCst); + ready.store(true, Ordering::SeqCst); + waker + .lock() + .expect("audio barrier waker poisoned") + .take() + .expect("poll_ready registered a waker") + .wake(); + + assert!(!task.await.expect("audio send task joins")); + assert!( + !sent.load(Ordering::SeqCst), + "authority loss while readiness is pending must prevent start_send" + ); + } + + #[test] + fn audio_membership_dispositions_retain_the_server_resolved_parent() { + let parent = Uuid::new_v4(); + for membership in [ + AudioMembership::ExistingMember { + lifecycle_parent_id: parent, + }, + AudioMembership::LegacyOpenGuest { + lifecycle_parent_id: parent, + }, + AudioMembership::LegacyAutoAdd { + lifecycle_parent_id: parent, + added_by: vec![7; 32], + }, + ] { + assert_eq!(membership.lifecycle_parent_id(), parent); + } + } + + #[test] + fn durable_visibility_precedes_remote_and_local_publication() { + let source = include_str!("handler.rs"); + let protected_path = source + .split_once("let admission = if let Some(admission_id) = protected_admission_id") + .expect("protected admission path") + .1; + let visibility = protected_path + .find("receipt.mark_visible().await") + .expect("durable visibility transition"); + for effect in [ + "crate::audio::join::activate_remote_owner", + "crate::audio::join::confirm_remote_owner", + "local_reservation.activate_protected_if", + ] { + let effect = protected_path + .find(effect) + .expect("protected effect boundary"); + assert!( + visibility < effect, + "durable visibility must commit before {effect}" + ); + } + } + #[test] fn audio_connection_permits_share_the_global_websocket_budget() { let semaphore = Arc::new(Semaphore::new(1)); diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index 6cf60c3113..1060f19d40 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -45,14 +45,17 @@ use buzz_relay_mesh::{ }; use dashmap::DashMap; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::debug; use uuid::Uuid; -use super::mesh::spawn_remote_peer_sink; +use super::mesh::{prepare_remote_peer_sink, spawn_remote_peer_sink, RemotePeerSinkGuard}; use super::room::{ - AdmissionError, AudioRoomManager, Room, RosterDelta as RoomRosterDelta, RosterPeer, + AdmissionError, AudioRoomManager, PendingAudioPeer, ProtectedDeadlineSchedule, + ProtectedPeerEffects, ProtectedPeerEpoch, Room, RoomOwnerEpoch, RosterDelta as RoomRosterDelta, + RosterPeer, }; use crate::tunnel::directory::{ReleaseResult, RenewResult, SessionDirectory, SessionLease}; @@ -330,6 +333,20 @@ impl JoinOutcome { } } +/// Revalidate the exact Redis owner/generation immediately before a prepared +/// room admission becomes visible. The caller must perform no asynchronous +/// work between this check and the synchronous room activation. +pub async fn validate_join_before_visibility( + directory: &D, + community_id: CommunityId, + session_id: Uuid, + local_runtime_id: RuntimeId, + outcome: JoinOutcome, +) -> Result<(), MeshError> { + let fenced = outcome.fenced_header(session_id, local_runtime_id); + directory.validate(community_id, &fenced).await +} + /// Outcome of [`resolve_join`]: the routing verdict plus, on the arm that /// freshly acquired the lease, the real [`HuddleLease`] to install in the /// [`HuddleOwnerRegistry`]. @@ -649,6 +666,7 @@ struct HuddleOwnerEntry { /// Owner-side signals for one huddle epoch. Returned atomically from attach so /// the CAS winner cannot miss a concurrent drain between installing the owner /// entry and looking the drain token back up. +#[derive(Clone)] pub struct HuddleOwnerSignals { /// Fenced-loss signal. pub lost: CancellationToken, @@ -683,6 +701,25 @@ impl HuddleOwnerRegistry { self.entries.get(&session_id).map(|e| e.draining.clone()) } + /// Return live signals only for the exact owner generation carried by a + /// control stream. A missing or superseded registry entry is not authority. + pub fn signals_for(&self, session_id: Uuid, generation: u64) -> Option { + self.entries.get(&session_id).and_then(|entry| { + (entry.generation == generation + && !entry.lost.is_cancelled() + && !entry.draining.is_cancelled()) + .then(|| HuddleOwnerSignals { + lost: entry.lost.clone(), + draining: entry.draining.clone(), + }) + }) + } + + /// Whether this runtime still owns the exact live epoch. + pub fn is_current(&self, session_id: Uuid, generation: u64) -> bool { + self.signals_for(session_id, generation).is_some() + } + /// Install the single per-room renewer for a freshly-acquired lease and /// return its `lost` signal. /// @@ -723,15 +760,43 @@ impl HuddleOwnerRegistry { } let generation = lease.generation(); if let Some(existing) = self.entries.get(&session_id) { - // A live entry already owns this room; release our extra lease - // cleanly rather than leaving two renewers on one session. - let cancel = CancellationToken::new(); - cancel.cancel(); - spawn_observable_huddle_renewer(directory, lease, cancel); - return HuddleOwnerSignals { - lost: existing.lost.clone(), - draining: existing.draining.clone(), - }; + if existing.generation == generation + && !existing.lost.is_cancelled() + && !existing.draining.is_cancelled() + { + // A live entry already owns this exact epoch; release our + // duplicate lease rather than leaving two renewers. + let cancel = CancellationToken::new(); + cancel.cancel(); + spawn_observable_huddle_renewer(directory, lease, cancel); + return HuddleOwnerSignals { + lost: existing.lost.clone(), + draining: existing.draining.clone(), + }; + } + if existing.generation > generation { + // A stale acquisition can never replace a newer observed + // epoch. Release it and return a fail-closed signal set. + let cancel = CancellationToken::new(); + cancel.cancel(); + spawn_observable_huddle_renewer(directory, lease, cancel); + let lost = CancellationToken::new(); + let draining = CancellationToken::new(); + lost.cancel(); + draining.cancel(); + return HuddleOwnerSignals { lost, draining }; + } + let stale_generation = existing.generation; + drop(existing); + self.entries.remove_if(&session_id, |_, entry| { + if entry.generation == stale_generation { + entry.draining.cancel(); + entry.cancel.cancel(); + true + } else { + false + } + }); } let cancel = CancellationToken::new(); let renewer = spawn_observable_huddle_renewer(directory, lease, cancel.clone()); @@ -841,7 +906,7 @@ impl HuddleOwnerRegistry { /// [`MeshStreamFrame::Data`](buzz_relay_mesh::MeshStreamFrame)`.payload`, /// postcard-encoded. This schema is owned by the huddle lane; the mesh wire /// layer treats it as opaque bytes. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum HuddleControlMsg { /// Non-owner → owner: register a local client as a remote peer in the /// owner's room. The owner allocates the `peer_index`. @@ -913,6 +978,92 @@ pub enum HuddleControlMsg { /// Pubkey of the departing client. pubkey: String, }, + // Protected variants are append-only so legacy postcard discriminants stay + // stable during a rolling upgrade. + /// Non-owner → owner: reserve a non-visible protected attachment. + ReservePeer { + /// Community that owns the huddle. + community_id: Uuid, + /// Durable PostgreSQL authorization attempt used only for correlation. + admission_id: Uuid, + /// Nostr pubkey hex of the joining client. + pubkey: String, + /// Huddle audio protocol version. + protocol_version: u8, + }, + /// Owner → non-owner: a protected attachment has a non-visible index. + PeerReserved { + /// Correlated durable authorization attempt. + admission_id: Uuid, + /// Pubkey the reservation was for. + pubkey: String, + /// Owner-allocated index, not yet visible in the roster. + peer_index: u8, + }, + /// Non-owner → owner: activate a previously reserved attachment. + ActivatePeer { + /// Correlated durable authorization attempt. + admission_id: Uuid, + }, + /// Owner → non-owner: the reserved attachment is ready but still hidden. + PeerActivated { + /// Correlated durable authorization attempt. + admission_id: Uuid, + /// Pubkey the activation was for. + pubkey: String, + /// Owner-allocated index. + peer_index: u8, + /// Complete roster before the reserved peer becomes visible. + roster: RosterSnapshot, + }, + /// Either side → owner: compensate a pending or active protected attempt. + AbortPeer { + /// Correlated durable authorization attempt. + admission_id: Uuid, + }, + /// Non-owner → owner: publish a prepared attachment after final revalidation. + ConfirmPeer { + /// Correlated durable authorization attempt. + admission_id: Uuid, + /// Relay-signed current authority bound to this exact attachment. + authority: String, + }, + /// Owner → non-owner: the prepared attachment is now visible. + PeerConfirmed { + /// Correlated durable authorization attempt. + admission_id: Uuid, + /// Pubkey the confirmation was for. + pubkey: String, + /// Owner-allocated index. + peer_index: u8, + /// Complete roster after confirmation. + roster: RosterSnapshot, + }, +} + +impl std::fmt::Debug for HuddleControlMsg { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let variant = match self { + Self::RegisterPeer { .. } => "RegisterPeer", + Self::PeerRegistered { .. } => "PeerRegistered", + Self::RosterSnapshot { .. } => "RosterSnapshot", + Self::RosterDelta { .. } => "RosterDelta", + Self::RosterResync => "RosterResync", + Self::RegisterRejected { .. } => "RegisterRejected", + Self::UnregisterPeer { .. } => "UnregisterPeer", + Self::ReservePeer { .. } => "ReservePeer", + Self::PeerReserved { .. } => "PeerReserved", + Self::ActivatePeer { .. } => "ActivatePeer", + Self::PeerActivated { .. } => "PeerActivated", + Self::AbortPeer { .. } => "AbortPeer", + Self::ConfirmPeer { .. } => "ConfirmPeer", + Self::PeerConfirmed { .. } => "PeerConfirmed", + }; + formatter + .debug_tuple("HuddleControlMsg") + .field(&variant) + .finish() + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1017,6 +1168,23 @@ pub fn decode_control(bytes: &[u8]) -> Result { postcard::from_bytes(bytes).map_err(MeshError::Decode) } +/// Opaque context bound into a protected cross-node audio confirmation. +pub(crate) fn protected_audio_attachment_context( + community_id: CommunityId, + channel_id: Uuid, + admission_id: Uuid, + pubkey: &str, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-audio-attachment-v1"); + digest.update(community_id.as_uuid().as_bytes()); + digest.update(channel_id.as_bytes()); + digest.update(admission_id.as_bytes()); + digest.update((pubkey.len() as u64).to_be_bytes()); + digest.update(pubkey.as_bytes()); + digest.finalize().into() +} + /// The tunnel profile these control messages ride. `HuddleControl` is a /// reliable stream — a dropped roster delta is an unrecoverable peer-index /// desync, so it never rides datagrams. @@ -1049,6 +1217,33 @@ pub struct HuddleControlAcceptor { /// the *same* renewer's `lost` — the loss surfaces as a proactive /// `Goodbye(StaleGeneration)` to each non-owner pod. owners: Arc, + authority_verifier: Option, + media_attachments: Arc, +} + +struct ProtectedRegisteredPeer { + pubkey: String, + protocol_version: u8, + peer_id: Uuid, + room: std::sync::Weak, + epoch: ProtectedPeerEpoch, + authority: crate::authorization_runtime::ephemeral::RetainedEphemeralAuthority, + effects: ProtectedPeerEffects, +} + +impl Drop for ProtectedRegisteredPeer { + fn drop(&mut self) { + self.effects.revoke(); + if let Some(room) = self.room.upgrade() { + room.remove_protected_epoch(self.epoch); + } + } +} + +struct LegacyRegisteredPeer { + peer_id: Uuid, + remote_sink: RemotePeerSinkGuard, + _media_attachment: super::mesh::MediaAttachmentGuard, } impl HuddleControlAcceptor { @@ -1062,6 +1257,7 @@ impl HuddleControlAcceptor { directory: Arc, local_runtime_id: RuntimeId, owners: Arc, + media_attachments: Arc, ) -> Self { Self { rooms, @@ -1069,9 +1265,20 @@ impl HuddleControlAcceptor { directory, local_runtime_id, owners, + authority_verifier: None, + media_attachments, } } + /// Require current database authority for protected peer confirmation. + pub(crate) fn with_authority_verifier( + mut self, + verifier: crate::authorization_runtime::ephemeral::AuthorityTokenVerifier, + ) -> Self { + self.authority_verifier = Some(verifier); + self + } + /// Accept and validate an inbound `HuddleControl` stream, then serve its /// register/unregister control loop until the stream closes. /// @@ -1128,10 +1335,24 @@ impl HuddleControlAcceptor { }); } - let lost = self.owners.lost_for(fenced.session_id); - let draining = self.owners.drain_for(fenced.session_id); - self.serve_control_loop(from, fenced, stream, lost, draining) - .await + let mut signals = None; + for _ in 0..OWNER_READY_MAX_ATTEMPTS { + if let Some(current) = self + .owners + .signals_for(fenced.session_id, fenced.generation) + { + signals = Some(current); + break; + } + tokio::time::sleep(OWNER_READY_RETRY_INTERVAL).await; + } + let Some(signals) = signals else { + return Err(MeshError::Transport(format!( + "huddle owner epoch {}:{} is not attached", + fenced.session_id, fenced.generation + ))); + }; + self.serve_control_loop(from, fenced, stream, signals).await } /// Serve register/unregister frames for one non-owner pod's stream. @@ -1159,12 +1380,17 @@ impl HuddleControlAcceptor { from: RuntimeId, fenced: FencedHeader, mut stream: MeshStream, - lost: Option, - draining: Option, + signals: HuddleOwnerSignals, ) -> Result<(), MeshError> { let session_id = fenced.session_id; // pubkey -> peer_id, for UnregisterPeer and teardown on stream close. - let mut registered: std::collections::HashMap = + let mut registered: std::collections::HashMap = + std::collections::HashMap::new(); + // Protected attempts are reserved without visibility and activated by + // durable admission id. Dropping a pending handle compensates it. + let mut pending: std::collections::HashMap = + std::collections::HashMap::new(); + let mut protected_registered: std::collections::HashMap = std::collections::HashMap::new(); // Community (raw UUID) latched from the first RegisterPeer; every later // frame must agree. `None` until the first register arrives. @@ -1175,22 +1401,12 @@ impl HuddleControlAcceptor { // sends the matching proactive Goodbye. A stream faulting on its own // leaves this empty and the close stays silent, as before. let mut teardown_reason: Option = None; + let mut authority_tick = tokio::time::interval(std::time::Duration::from_millis(100)); + authority_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let result = loop { - // A future that never resolves when there is no loss signal, so the - // `select!` degenerates to a plain recv for the `None` case. - let lost_fired = async { - match &lost { - Some(token) => token.cancelled().await, - None => std::future::pending().await, - } - }; - let drain_fired = async { - match &draining { - Some(token) => token.cancelled().await, - None => std::future::pending().await, - } - }; + let lost_fired = signals.lost.cancelled(); + let drain_fired = signals.draining.cancelled(); let roster_event = async { match &mut roster_rx { Some(rx) => Some(rx.recv().await), @@ -1198,6 +1414,7 @@ impl HuddleControlAcceptor { } }; let frame = tokio::select! { + biased; _ = drain_fired => { teardown_reason = Some(GoodbyeReason::Draining); break Ok(()); @@ -1206,12 +1423,65 @@ impl HuddleControlAcceptor { teardown_reason = Some(GoodbyeReason::StaleGeneration); break Ok(()); } + _ = authority_tick.tick(), if !protected_registered.is_empty() => { + let checks: Vec<_> = protected_registered + .iter() + .map(|(admission_id, peer)| (*admission_id, peer.authority.clone())) + .collect(); + for (admission_id, authority) in checks { + if authority.release().await { + continue; + } + let Some(peer) = protected_registered.remove(&admission_id) else { + continue; + }; + drop(peer); + if let Some(room) = stream_community.and_then(|community_id| { + self.rooms.get(CommunityId::from_uuid(community_id), session_id) + }) { + let community = CommunityId::from_uuid( + stream_community.expect("protected peer requires a community"), + ); + let owner_epoch = + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + self.rooms.retire_exact_owner_if_empty( + community, + session_id, + &room, + owner_epoch, + || self.owners.release(session_id, fenced.generation), + ); + } + } + continue; + } event = roster_event => { let Some(event) = event else { continue; }; let msg = match event { - Ok(delta) => roster_delta_msg(delta), + Ok(delta) => { + let Some(community_id) = stream_community else { + break Ok(()); + }; + let Some(room) = self.rooms.get( + CommunityId::from_uuid(community_id), + session_id, + ) else { + break Ok(()); + }; + let current = room.roster_snapshot(); + if delta.joined.as_ref().is_some_and(|joined| { + !current.peers.iter().any(|peer| peer == joined) + }) { + HuddleControlMsg::RosterSnapshot { + revision: current.revision, + peers: current.peers.into_iter().map(Into::into).collect(), + } + } else { + roster_delta_msg(delta) + } + } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let Some(community_id) = stream_community else { break Ok(()); @@ -1261,7 +1531,608 @@ impl HuddleControlAcceptor { Err(e) => break Err(e), }; + if !self.owners.is_current(session_id, fenced.generation) { + teardown_reason = Some(if signals.draining.is_cancelled() { + GoodbyeReason::Draining + } else { + GoodbyeReason::StaleGeneration + }); + break Ok(()); + } + match msg { + HuddleControlMsg::ReservePeer { + community_id, + admission_id, + pubkey, + protocol_version, + } => { + match stream_community { + None => stream_community = Some(community_id), + Some(latched) if latched != community_id => { + break Err(MeshError::Transport(format!( + "huddle-control stream community changed {latched} -> {community_id}" + ))); + } + Some(_) => {} + } + if self.owners.is_draining() { + teardown_reason = Some(GoodbyeReason::Draining); + break Ok(()); + } + let community = CommunityId::from_uuid(community_id); + let owner_epoch = + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + let room_claim = match self.rooms.get_or_create_for_owner( + community, + session_id, + owner_epoch, + |old_epoch| { + if old_epoch.owner_runtime_id == self.local_runtime_id { + self.owners.release(session_id, old_epoch.generation); + } + }, + ) { + Ok(claim) => claim, + Err(_) => { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + }; + let room = room_claim.room(); + let reply = match self.directory.validate(community, &fenced).await { + Ok(()) if !self.owners.is_current(session_id, fenced.generation) => { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + Ok(()) => { + if let Some((existing_pubkey, existing_version, reservation)) = + pending.get(&admission_id) + { + if existing_pubkey != &pubkey + || *existing_version != protocol_version + { + break Err(MeshError::Transport( + "conflicting huddle reservation retry".into(), + )); + } + HuddleControlMsg::PeerReserved { + admission_id, + pubkey: pubkey.clone(), + peer_index: reservation.peer_index(), + } + } else if let Some(existing) = protected_registered.get(&admission_id) { + if existing.pubkey != pubkey + || existing.protocol_version != protocol_version + { + break Err(MeshError::Transport( + "conflicting active huddle retry".into(), + )); + } + let peer_index = room + .peers + .get(&existing.peer_id) + .map(|peer| peer.peer_index) + .ok_or_else(|| { + MeshError::Transport( + "active huddle retry lost its room peer".into(), + ) + })?; + HuddleControlMsg::PeerReserved { + admission_id, + pubkey: pubkey.clone(), + peer_index, + } + } else { + match room.reserve_remote_peer( + admission_id, + pubkey.clone(), + protocol_version, + from.0, + ) { + Ok(reservation) => { + let peer_index = reservation.peer_index(); + pending.insert( + admission_id, + (pubkey.clone(), protocol_version, reservation), + ); + HuddleControlMsg::PeerReserved { + admission_id, + pubkey: pubkey.clone(), + peer_index, + } + } + Err(reason) => HuddleControlMsg::RegisterRejected { + pubkey: pubkey.clone(), + reason: admission_to_rejection(reason), + }, + } + } + } + Err(e) => match FenceRejection::from_mesh_error(&e) { + Some(reason) => HuddleControlMsg::RegisterRejected { + pubkey: pubkey.clone(), + reason: RegisterRejection::Fenced(reason), + }, + None => break Err(e), + }, + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&reply)?, + }) + .await?; + } + HuddleControlMsg::ActivatePeer { admission_id } => { + let Some(community_id) = stream_community else { + break Err(MeshError::Transport( + "huddle activation arrived before reservation".into(), + )); + }; + let community = CommunityId::from_uuid(community_id); + let owner_epoch = + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + let Some(room) = self.rooms.get(community, session_id) else { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + }; + if !room.matches_owner_epoch(owner_epoch) { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + let (pubkey, peer_index) = + if let Some((pubkey, _, reservation)) = pending.get(&admission_id) { + (pubkey.clone(), reservation.peer_index()) + } else if let Some(existing) = protected_registered.get(&admission_id) { + let peer_index = room + .peers + .get(&existing.peer_id) + .map(|peer| peer.peer_index) + .ok_or_else(|| { + MeshError::Transport( + "active huddle retry lost its room peer".into(), + ) + })?; + (existing.pubkey.clone(), peer_index) + } else { + break Err(MeshError::Transport( + "unknown huddle admission attempt".into(), + )); + }; + if let Err(e) = self.directory.validate(community, &fenced).await { + pending.remove(&admission_id); + let Some(reason) = FenceRejection::from_mesh_error(&e) else { + break Err(e); + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced(reason), + })?, + }) + .await?; + continue; + } + if !self.owners.is_current(session_id, fenced.generation) { + pending.remove(&admission_id); + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + // Preparation is deliberately non-visible. The origin + // revalidates its PostgreSQL-authorized attempt before it + // sends ConfirmPeer, which owns the roster-visible effect. + let reply = HuddleControlMsg::PeerActivated { + admission_id, + pubkey, + peer_index, + roster: roster_snapshot(&room), + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&reply)?, + }) + .await?; + } + HuddleControlMsg::ConfirmPeer { + admission_id, + authority, + } => { + let Some(community_id) = stream_community else { + break Err(MeshError::Transport( + "huddle confirmation arrived before reservation".into(), + )); + }; + let community = CommunityId::from_uuid(community_id); + let owner_epoch = + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + let Some(room) = self.rooms.get(community, session_id) else { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + }; + if !room.matches_owner_epoch(owner_epoch) { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + let pubkey = protected_registered + .get(&admission_id) + .map(|peer| peer.pubkey.clone()) + .or_else(|| { + pending + .get(&admission_id) + .map(|(pubkey, _, _)| pubkey.clone()) + }) + .ok_or_else(|| { + MeshError::Transport("unknown huddle admission attempt".into()) + })?; + let Some(verifier) = &self.authority_verifier else { + break Err(MeshError::Transport( + "protected huddle authority verifier is unavailable".into(), + )); + }; + let context_id = protected_audio_attachment_context( + community, + session_id, + admission_id, + &pubkey, + ); + let verified_authority = match verifier + .verify_context(community, context_id, &authority) + .await + { + Ok(authority) => authority, + Err(_) => { + pending.remove(&admission_id); + if let Some(peer) = protected_registered.remove(&admission_id) { + drop(peer); + } + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + } + }; + if let Some(existing_authority) = protected_registered + .get(&admission_id) + .map(|peer| peer.authority.clone()) + { + if !existing_authority.release().await { + if let Some(peer) = protected_registered.remove(&admission_id) { + drop(peer); + } + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + } + if self.directory.validate(community, &fenced).await.is_err() { + if let Some(peer) = protected_registered.remove(&admission_id) { + drop(peer); + } + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + if !self.owners.is_current(session_id, fenced.generation) { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + let peer = protected_registered + .get(&admission_id) + .expect("revalidated protected peer remains registered"); + let peer_index = room + .peers + .get(&peer.peer_id) + .map(|peer| peer.peer_index) + .ok_or_else(|| { + MeshError::Transport( + "confirmed huddle retry lost its room peer".into(), + ) + })?; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::PeerConfirmed { + admission_id, + pubkey: peer.pubkey.clone(), + peer_index, + roster: roster_snapshot(&room), + })?, + }) + .await?; + continue; + } + if let Err(e) = self.directory.validate(community, &fenced).await { + pending.remove(&admission_id); + let Some(reason) = FenceRejection::from_mesh_error(&e) else { + break Err(e); + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced(reason), + })?, + }) + .await?; + continue; + } + // This is the last await before the synchronous visibility + // transition. Keep the peer reserved and invisible until + // both PostgreSQL authority and Redis ownership are fresh. + if !verified_authority.release().await { + pending.remove(&admission_id); + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + } + // PostgreSQL revalidation awaited after the first Redis + // fence. Revalidate Redis again so neither authority is a + // stale preflight when the synchronous room transition + // begins; the live owner signal compensates later loss. + if let Err(e) = self.directory.validate(community, &fenced).await { + pending.remove(&admission_id); + let Some(reason) = FenceRejection::from_mesh_error(&e) else { + break Err(e); + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced(reason), + })?, + }) + .await?; + continue; + } + if !verified_authority.release().await { + pending.remove(&admission_id); + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + } + if !verified_authority.is_time_valid() + || !self.owners.is_current(session_id, fenced.generation) + { + pending.remove(&admission_id); + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + let new_roster_rx = room.subscribe_roster(); + let (_, protocol_version, reservation) = pending + .remove(&admission_id) + .expect("pending admission checked above"); + let reserved_peer_index = reservation.peer_index(); + let Some(media_attachment) = self.media_attachments.register_owner_ingress( + fenced, + from, + reserved_peer_index, + admission_id, + verified_authority.expires_at(), + ) else { + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + }; + let effects = ProtectedPeerEffects::new(CancellationToken::new()); + let schedule = + match ProtectedDeadlineSchedule::new(verified_authority.expires_at(), None) + { + Ok(schedule) => schedule, + Err(_) => { + effects.revoke(); + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control( + &HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + }, + )?, + }) + .await?; + continue; + } + }; + if !effects.install_revoker(move || drop(media_attachment)) { + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + } + let owners = Arc::clone(&self.owners); + let ((peer_id, peer_index, audio_rx, _peer_ctrl_rx), protected_epoch) = + match reservation.activate_protected_with_effects_if( + schedule, + effects.clone(), + || { + verified_authority.is_time_valid() + && owners.is_current(session_id, fenced.generation) + }, + ) { + Ok(Some(activated)) => activated, + Ok(None) => { + effects.revoke(); + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control( + &HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + }, + )?, + }) + .await?; + continue; + } + Err(reason) => { + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control( + &HuddleControlMsg::RegisterRejected { + pubkey, + reason: admission_to_rejection(reason), + }, + )?, + }) + .await?; + continue; + } + }; + // Prepare and start the exact compensated media effect + // while the peer is still hidden. Publication is the sole + // visibility point and cannot race an unowned sink. + let (remote_sink, remote_sink_start) = prepare_remote_peer_sink( + Arc::clone(&self.transport), + from, + fenced, + audio_rx, + Some(schedule.wake_at()), + ); + if !effects.install_revoker(move || remote_sink.close()) + || !remote_sink_start.start() + || !effects.is_live() + { + room.remove_protected_epoch(protected_epoch); + continue; + } + if !verified_authority.is_time_valid() + || !self.owners.is_current(session_id, fenced.generation) + { + room.remove_protected_epoch(protected_epoch); + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } + let Some(owner_roster) = room.broadcast_protected_join_if_current( + protected_epoch, + &pubkey, + peer_index, + ) else { + room.remove_protected_epoch(protected_epoch); + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterRejected { + pubkey, + reason: RegisterRejection::Fenced( + FenceRejection::NoActiveLease, + ), + })?, + }) + .await?; + continue; + }; + protected_registered.insert( + admission_id, + ProtectedRegisteredPeer { + pubkey: pubkey.clone(), + protocol_version, + peer_id, + room: Arc::downgrade(&room), + epoch: protected_epoch, + authority: verified_authority, + effects, + }, + ); + let reply = HuddleControlMsg::PeerConfirmed { + admission_id, + pubkey, + peer_index, + roster: roster_snapshot_from_room(owner_roster), + }; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&reply)?, + }) + .await?; + roster_rx = Some(new_roster_rx); + } + HuddleControlMsg::AbortPeer { admission_id } => { + pending.remove(&admission_id); + if let Some(peer) = protected_registered.remove(&admission_id) { + drop(peer); + if let Some(room) = stream_community.and_then(|community_id| { + self.rooms + .get(CommunityId::from_uuid(community_id), session_id) + }) { + let community = CommunityId::from_uuid( + stream_community.expect("registered peer requires a community"), + ); + let owner_epoch = + RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + self.rooms.retire_exact_owner_if_empty( + community, + session_id, + &room, + owner_epoch, + || self.owners.release(session_id, fenced.generation), + ); + } + } + } HuddleControlMsg::RegisterPeer { community_id, pubkey, @@ -1299,6 +2170,10 @@ impl HuddleControlAcceptor { // that revision are ignored by the receiver. let new_roster_rx = room.subscribe_roster(); let reply = match self.directory.validate(community, &fenced).await { + Ok(()) if !self.owners.is_current(session_id, fenced.generation) => { + teardown_reason = Some(GoodbyeReason::StaleGeneration); + break Ok(()); + } Ok(()) => self.register_remote_peer( Arc::clone(&room), fenced, @@ -1329,13 +2204,15 @@ impl HuddleControlAcceptor { } } HuddleControlMsg::UnregisterPeer { pubkey } => { - if let Some(peer_id) = registered.remove(&pubkey) { + if let Some(peer) = registered.remove(&pubkey) { + peer.remote_sink.close(); if let Some(room) = stream_community.and_then(|community_id| { self.rooms .get(CommunityId::from_uuid(community_id), session_id) }) { - let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); - room.remove_peer(peer_id); + let peer_index = + room.peers.get(&peer.peer_id).map(|entry| entry.peer_index); + room.remove_peer(peer.peer_id); if let Some(peer_index) = peer_index { room.broadcast_control( serde_json::json!({ @@ -1366,6 +2243,9 @@ impl HuddleControlAcceptor { // Owner→non-owner replies never arrive on the owner's accept // side; a peer sending one is a protocol violation. HuddleControlMsg::PeerRegistered { .. } + | HuddleControlMsg::PeerReserved { .. } + | HuddleControlMsg::PeerActivated { .. } + | HuddleControlMsg::PeerConfirmed { .. } | HuddleControlMsg::RosterSnapshot { .. } | HuddleControlMsg::RosterDelta { .. } | HuddleControlMsg::RegisterRejected { .. } => { @@ -1376,26 +2256,20 @@ impl HuddleControlAcceptor { } }; - // Owner-initiated teardown: tell the non-owner pod why this owner is - // closing so it can rejoin against Redis. Best-effort — teardown - // proceeds even if the stream is already gone. Normal stream/client - // closes stay silent. - if let Some(reason) = teardown_reason { - let _ = stream - .send_frame(MeshStreamFrame::Goodbye { fenced, reason }) - .await; - } - // Teardown: drop every peer this stream registered, regardless of how // the loop ended. Dropping the peer drops its `audio_tx`, which ends the - // matching `spawn_remote_peer_sink` task. + // matching `spawn_remote_peer_sink` task. This must happen before an + // owner-loss Goodbye: a backpressured control stream must never retain + // old-generation media authority. if let Some(room) = stream_community.and_then(|community_id| { self.rooms .get(CommunityId::from_uuid(community_id), session_id) }) { - for (pubkey, peer_id) in registered { - let peer_index = room.peers.get(&peer_id).map(|peer| peer.peer_index); - room.remove_peer(peer_id); + pending.clear(); + for (pubkey, peer) in registered { + peer.remote_sink.close(); + let peer_index = room.peers.get(&peer.peer_id).map(|entry| entry.peer_index); + room.remove_peer(peer.peer_id); if let Some(peer_index) = peer_index { room.broadcast_control( serde_json::json!({ @@ -1407,9 +2281,37 @@ impl HuddleControlAcceptor { ); } } + for (_, peer) in protected_registered { + drop(peer); + } + let community = + CommunityId::from_uuid(stream_community.expect("room lookup required a community")); + let owner_epoch = RoomOwnerEpoch::new(fenced.owner_runtime_id, fenced.generation); + if room.matches_owner_epoch(owner_epoch) { + self.rooms.retire_exact_owner_if_empty( + community, + session_id, + &room, + owner_epoch, + || self.owners.release(session_id, fenced.generation), + ); + } } - result - } + // Owner-initiated teardown: after every peer and media attachment is + // revoked, tell the non-owner why the owner is closing. Bound the + // best-effort send so control backpressure cannot delay completion. + if let Some(reason) = teardown_reason { + let result = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(MeshStreamFrame::Goodbye { fenced, reason }), + ) + .await; + if !matches!(result, Ok(Ok(()))) { + let _ = stream.finish(); + } + } + result + } /// Admit one remote client into the owner's room and wire its fan-out back /// to the registering pod as datagrams. Returns the reply to send. @@ -1420,15 +2322,36 @@ impl HuddleControlAcceptor { from: RuntimeId, pubkey: &str, protocol_version: u8, - registered: &mut std::collections::HashMap, + registered: &mut std::collections::HashMap, ) -> HuddleControlMsg { - match room.add_peer(pubkey.to_string(), protocol_version) { + match room.add_remote_peer(pubkey.to_string(), protocol_version, from.0) { Ok((peer_id, peer_index, audio_rx, _peer_ctrl_rx)) => { - registered.insert(pubkey.to_string(), peer_id); + let Some(media_attachment) = self.media_attachments.register_owner_ingress( + fenced, + from, + peer_index, + peer_id, + u64::MAX, + ) else { + room.remove_peer(peer_id); + return HuddleControlMsg::RegisterRejected { + pubkey: pubkey.to_string(), + reason: RegisterRejection::Fenced(FenceRejection::NoActiveLease), + }; + }; + let remote_sink = + spawn_remote_peer_sink(Arc::clone(&self.transport), from, fenced, audio_rx); + registered.insert( + pubkey.to_string(), + LegacyRegisteredPeer { + peer_id, + remote_sink, + _media_attachment: media_attachment, + }, + ); // The owner's Room fans out to this remote peer's `audio_tx`; // the sink drains `audio_rx` and ships each frame as a datagram // to the pod that hosts the client. - spawn_remote_peer_sink(Arc::clone(&self.transport), from, fenced, audio_rx); let joined = serde_json::json!({ "type": "joined", "pubkey": pubkey, @@ -1452,7 +2375,10 @@ impl HuddleControlAcceptor { } fn roster_snapshot(room: &Room) -> RosterSnapshot { - let snapshot = room.roster_snapshot(); + roster_snapshot_from_room(room.roster_snapshot()) +} + +fn roster_snapshot_from_room(snapshot: super::room::RosterSnapshot) -> RosterSnapshot { RosterSnapshot { revision: snapshot.revision, peers: snapshot.peers.into_iter().map(Into::into).collect(), @@ -1503,6 +2429,45 @@ pub const HUDDLE_SESSION_ENDED: GoodbyeReason = GoodbyeReason::SessionEnded; // the owner round-trip — `deliver_prefixed` skips a client's own index so it // never hears itself). +/// A non-visible remote reservation correlated to a PostgreSQL admission. +pub struct PendingRemoteHuddleSession { + admission_id: Uuid, + peer_index: u8, + fenced: FencedHeader, + owner: RuntimeId, + pubkey: String, + transport: Arc, +} + +/// Inputs bound to one protected remote attachment attempt. +pub struct RemoteReservationRequest { + /// Server-resolved community. + pub community_id: CommunityId, + /// Durable PostgreSQL admission correlation id. + pub admission_id: Uuid, + /// Joining client's Nostr pubkey hex. + pub pubkey: String, + /// Negotiated huddle protocol version. + pub protocol_version: u8, +} + +impl PendingRemoteHuddleSession { + /// Owner-assigned index reserved for this attempt. + pub fn peer_index(&self) -> u8 { + self.peer_index + } + + /// Correlated durable admission attempt. + pub fn admission_id(&self) -> Uuid { + self.admission_id + } + + /// Owner-generation fence for compensation. + pub fn fenced(&self) -> FencedHeader { + self.fenced + } +} + /// A registered cross-pod huddle session on the non-owner side. /// /// Holds everything needed to forward the local client's media to the owner and @@ -1521,6 +2486,10 @@ pub struct RemoteHuddleSession { owner: RuntimeId, /// Pubkey of the local client, for the closing `UnregisterPeer`. pubkey: String, + /// Protected attempt to abort on teardown; absent on the legacy protocol. + admission_id: Option, + /// Exact local admission generation permitted to author outbound media. + local_epoch: Option, /// Transport for datagrams and the control-stream teardown. transport: Arc, /// Per-datagram monotonic sequence for loss/reorder observability. @@ -1746,6 +2715,8 @@ pub async fn dial_remote_owner( fenced, owner, pubkey, + admission_id: None, + local_epoch: None, transport, seq: 0, }, @@ -1765,11 +2736,173 @@ pub async fn dial_remote_owner( } } +/// Reserve a protected remote attachment without making it roster-visible. +pub async fn reserve_remote_owner( + transport: Arc, + local_runtime_id: RuntimeId, + owner: RuntimeId, + fenced: FencedHeader, + request: RemoteReservationRequest, +) -> Result<(PendingRemoteHuddleSession, MeshStream), DialError> { + let hello = StreamHello { + sender: local_runtime_id, + role: StreamRole::Session { + fenced, + profile: Profile::HuddleControl, + }, + }; + let mut stream = transport.open_session_stream(owner, hello).await?; + stream + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ReservePeer { + community_id: *request.community_id.as_uuid(), + admission_id: request.admission_id, + pubkey: request.pubkey.clone(), + protocol_version: request.protocol_version, + })?, + }) + .await?; + match stream.recv_frame().await? { + Some(MeshStreamFrame::Data { payload, .. }) => match decode_control(&payload)? { + HuddleControlMsg::PeerReserved { + admission_id: reply_id, + peer_index, + .. + } if reply_id == request.admission_id => Ok(( + PendingRemoteHuddleSession { + admission_id: request.admission_id, + peer_index, + fenced, + owner, + pubkey: request.pubkey, + transport, + }, + stream, + )), + HuddleControlMsg::RegisterRejected { reason, .. } => Err(DialError::Rejected(reason)), + other => Err(DialError::Mesh(MeshError::Transport(format!( + "expected PeerReserved/RegisterRejected, got {other:?}" + )))), + }, + Some(MeshStreamFrame::Goodbye { .. }) | None => Err(DialError::Mesh(MeshError::Transport( + "owner closed HuddleControl stream before reserving".into(), + ))), + Some(other) => Err(DialError::Mesh(MeshError::Transport(format!( + "unexpected HuddleControl frame from owner: {other:?}" + )))), + } +} + +/// Prepare a previously reserved protected remote attachment without making it visible. +pub async fn activate_remote_owner( + pending: &PendingRemoteHuddleSession, + stream: &mut MeshStream, +) -> Result { + stream + .send_frame(MeshStreamFrame::Data { + fenced: pending.fenced, + payload: encode_control(&HuddleControlMsg::ActivatePeer { + admission_id: pending.admission_id, + })?, + }) + .await?; + match stream.recv_frame().await? { + Some(MeshStreamFrame::Data { payload, .. }) => match decode_control(&payload)? { + HuddleControlMsg::PeerActivated { + admission_id, + peer_index, + roster, + .. + } if admission_id == pending.admission_id && peer_index == pending.peer_index => { + Ok(roster) + } + HuddleControlMsg::RegisterRejected { reason, .. } => Err(DialError::Rejected(reason)), + other => Err(DialError::Mesh(MeshError::Transport(format!( + "expected PeerActivated/RegisterRejected, got {other:?}" + )))), + }, + Some(MeshStreamFrame::Goodbye { .. }) | None => Err(DialError::Mesh(MeshError::Transport( + "owner closed HuddleControl stream before activation".into(), + ))), + Some(other) => Err(DialError::Mesh(MeshError::Transport(format!( + "unexpected HuddleControl frame from owner: {other:?}" + )))), + } +} + +/// Confirm a prepared remote attachment after the origin revalidates authority. +pub async fn confirm_remote_owner( + pending: PendingRemoteHuddleSession, + stream: &mut MeshStream, + authority: String, +) -> Result { + stream + .send_frame(MeshStreamFrame::Data { + fenced: pending.fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id: pending.admission_id, + authority, + })?, + }) + .await?; + match stream.recv_frame().await? { + Some(MeshStreamFrame::Data { payload, .. }) => match decode_control(&payload)? { + HuddleControlMsg::PeerConfirmed { + admission_id, + peer_index, + roster, + .. + } if admission_id == pending.admission_id && peer_index == pending.peer_index => { + Ok(RemoteHuddleSession { + peer_index, + roster, + fenced: pending.fenced, + owner: pending.owner, + pubkey: pending.pubkey, + admission_id: Some(pending.admission_id), + local_epoch: None, + transport: pending.transport, + seq: 0, + }) + } + HuddleControlMsg::RegisterRejected { reason, .. } => Err(DialError::Rejected(reason)), + other => Err(DialError::Mesh(MeshError::Transport(format!( + "expected PeerConfirmed/RegisterRejected, got {other:?}" + )))), + }, + Some(MeshStreamFrame::Goodbye { .. }) | None => Err(DialError::Mesh(MeshError::Transport( + "owner closed HuddleControl stream before confirmation".into(), + ))), + Some(other) => Err(DialError::Mesh(MeshError::Transport(format!( + "unexpected HuddleControl frame from owner: {other:?}" + )))), + } +} + +/// Compensate a protected remote reservation or active attachment. +pub async fn abort_remote_owner(stream: &mut MeshStream, fenced: FencedHeader, admission_id: Uuid) { + if let Ok(payload) = encode_control(&HuddleControlMsg::AbortPeer { admission_id }) { + let result = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(MeshStreamFrame::Data { fenced, payload }), + ) + .await; + if !matches!(result, Ok(Ok(()))) { + let _ = stream.finish(); + } + } +} + /// The `StreamHello.sender` for a dialed session: the fenced header carries the /// owner's identity, but the *sender* is this pod. The owner validates /// `hello.sender == authenticated peer`, so it must be our own runtime id — the /// handler threads `local_runtime_id` in explicitly. impl RemoteHuddleSession { + pub(crate) fn bind_local_epoch(&mut self, epoch: ProtectedPeerEpoch) { + self.local_epoch = Some(epoch); + } + /// The owner-assigned index this client occupies in the owner's room. pub fn peer_index(&self) -> u8 { self.peer_index @@ -1791,14 +2924,25 @@ impl RemoteHuddleSession { &self.pubkey } + /// Protected admission attempt, absent for the legacy registration path. + pub fn admission_id(&self) -> Option { + self.admission_id + } + /// Forward one client Opus frame to the owner as a media datagram, tagged /// with the owner-assigned index. Drop-on-error: realtime audio never blocks /// on a slow or gone link (the same discipline as local fan-out). - pub fn forward_media(&mut self, client_frame: &[u8]) { + pub fn forward_media(&mut self, room: &Room, client_frame: &[u8]) { + if self + .local_epoch + .is_some_and(|epoch| !room.is_protected_epoch_current(epoch)) + { + return; + } let dgram = media_datagram(self.peer_index, self.fenced, self.seq, client_frame); self.seq = self.seq.wrapping_add(1); if let Err(e) = self.transport.send_datagram(self.owner, dgram) { - debug!(owner = %self.owner, "huddle media datagram to owner failed: {e}"); + debug!("huddle media datagram to owner failed: {e}"); } } } @@ -1813,19 +2957,41 @@ pub async fn send_clean_close(stream: &mut MeshStream, fenced: FencedHeader, pub if let Ok(payload) = encode_control(&HuddleControlMsg::UnregisterPeer { pubkey: pubkey.to_string(), }) { - let _ = stream - .send_frame(MeshStreamFrame::Data { fenced, payload }) - .await; + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(MeshStreamFrame::Data { fenced, payload }), + ) + .await; } - let _ = stream - .send_frame(MeshStreamFrame::Goodbye { + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(MeshStreamFrame::Goodbye { fenced, reason: HUDDLE_SESSION_ENDED, - }) - .await; + }), + ) + .await; let _ = stream.finish(); } +/// Close a remote session using protected compensation or legacy unregister. +pub async fn send_remote_close(stream: &mut MeshStream, session: &RemoteHuddleSession) { + if let Some(admission_id) = session.admission_id() { + abort_remote_owner(stream, session.fenced(), admission_id).await; + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + stream.send_frame(MeshStreamFrame::Goodbye { + fenced: session.fenced(), + reason: HUDDLE_SESSION_ENDED, + }), + ) + .await; + let _ = stream.finish(); + } else { + send_clean_close(stream, session.fenced(), session.pubkey()).await; + } +} + /// Build the media datagram a non-owner ships to the owner for one client /// frame: `[owner_peer_index][client frame]`, stamped with the session fence /// and sequence. Pure so the framing is unit-testable without a live transport @@ -1961,6 +3127,68 @@ mod tests { } } + struct ConfirmBarrierDir { + validate_calls: std::sync::atomic::AtomicUsize, + block_on: usize, + entered: tokio::sync::Notify, + release: tokio::sync::Notify, + } + + impl ConfirmBarrierDir { + fn new(block_on: usize) -> Self { + Self { + validate_calls: std::sync::atomic::AtomicUsize::new(0), + block_on, + entered: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + } + } + } + + #[async_trait::async_trait] + impl HuddleDirectory for ConfirmBarrierDir { + async fn owner_of( + &self, + _community: CommunityId, + _session: Uuid, + ) -> Result, MeshError> { + Ok(None) + } + + async fn acquire( + &self, + _community: CommunityId, + _session: Uuid, + _owner: RuntimeId, + ) -> Result { + Err(MeshError::Transport("unexpected acquire".into())) + } + + async fn renew(&self, _lease: &HuddleLease) -> Result { + Err(MeshError::Transport("unexpected renew".into())) + } + + async fn release(&self, _lease: &HuddleLease) -> Result { + Err(MeshError::Transport("unexpected release".into())) + } + + async fn validate( + &self, + _community: CommunityId, + _fenced: &FencedHeader, + ) -> Result<(), MeshError> { + let call = self + .validate_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + if call == self.block_on { + self.entered.notify_one(); + self.release.notified().await; + } + Ok(()) + } + } + /// A `HuddleLease` for renewer tests: the inner `SessionLease` is opaque to /// the huddle lane, so any well-formed fenced tuple works. fn test_lease() -> HuddleLease { @@ -2074,7 +3302,49 @@ mod tests { #[test] fn control_msg_roundtrips() { + let admission_id = Uuid::new_v4(); for msg in [ + HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "abc123".into(), + protocol_version: 2, + }, + HuddleControlMsg::PeerReserved { + admission_id, + pubkey: "abc123".into(), + peer_index: 42, + }, + HuddleControlMsg::ActivatePeer { admission_id }, + HuddleControlMsg::PeerActivated { + admission_id, + pubkey: "abc123".into(), + peer_index: 42, + roster: RosterSnapshot { + revision: 1, + peers: vec![RosterEntry { + pubkey: "abc123".into(), + peer_index: 42, + }], + }, + }, + HuddleControlMsg::AbortPeer { admission_id }, + HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "synthetic-authority".into(), + }, + HuddleControlMsg::PeerConfirmed { + admission_id, + pubkey: "abc123".into(), + peer_index: 42, + roster: RosterSnapshot { + revision: 1, + peers: vec![RosterEntry { + pubkey: "abc123".into(), + peer_index: 42, + }], + }, + }, HuddleControlMsg::RegisterPeer { community_id: *community().as_uuid(), pubkey: "abc123".into(), @@ -2120,6 +3390,71 @@ mod tests { } } + #[test] + fn control_debug_redacts_authority_and_roster_identity() { + let message = HuddleControlMsg::ConfirmPeer { + admission_id: Uuid::from_u128(0xfeed), + authority: "sealed-private-authority".into(), + }; + let debug = format!("{message:?}"); + assert_eq!(debug, "HuddleControlMsg(\"ConfirmPeer\")"); + assert!(!debug.contains("sealed-private-authority")); + assert!(!debug.contains("feed")); + } + + #[test] + fn protected_control_messages_preserve_legacy_wire_discriminants() { + let legacy = [ + HuddleControlMsg::RegisterPeer { + community_id: Uuid::nil(), + pubkey: String::new(), + protocol_version: 1, + }, + HuddleControlMsg::PeerRegistered { + pubkey: String::new(), + peer_index: 0, + roster: RosterSnapshot { + revision: 0, + peers: vec![], + }, + }, + HuddleControlMsg::RosterSnapshot { + revision: 0, + peers: vec![], + }, + HuddleControlMsg::RosterDelta { + revision: 0, + joined: None, + left: None, + }, + HuddleControlMsg::RosterResync, + HuddleControlMsg::RegisterRejected { + pubkey: String::new(), + reason: RegisterRejection::RoomFull, + }, + HuddleControlMsg::UnregisterPeer { + pubkey: String::new(), + }, + ]; + for (expected, message) in legacy.into_iter().enumerate() { + assert_eq!( + encode_control(&message).unwrap()[0], + expected as u8, + "append-only protocol changes must not renumber legacy variants" + ); + } + assert_eq!( + encode_control(&HuddleControlMsg::ReservePeer { + community_id: Uuid::nil(), + admission_id: Uuid::nil(), + pubkey: String::new(), + protocol_version: 1, + }) + .unwrap()[0], + 7 + ); + } + // ── In-memory MeshStream pair for handshake round-trip tests ───────────── // // A channel-backed `StreamSendHalf`/`StreamRecvHalf` pair drives @@ -2162,34 +3497,100 @@ mod tests { (owner, client) } - #[tokio::test] - async fn roster_revision_gap_requests_resync_before_forwarding_new_state() { - let session_id = Uuid::new_v4(); - let fenced = fenced_owned_by(rt(1), session_id); - let (mut owner, mut client) = stream_pair(); - let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::channel(4); - let reader = - tokio::spawn(async move { read_owner_control(&mut client, fenced, 1, &ctrl_tx).await }); + struct BackpressuredSend(Arc); - owner - .send_frame(MeshStreamFrame::Data { - fenced, - payload: encode_control(&HuddleControlMsg::RosterDelta { - revision: 3, - joined: Some(RosterEntry { - pubkey: "bob".into(), - peer_index: 7, - }), - left: None, - }) - .unwrap(), - }) - .await - .unwrap(); + impl StreamSendHalf for BackpressuredSend { + fn send_frame(&mut self, _frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + Box::pin(std::future::pending()) + } - let request = owner.recv_frame().await.unwrap().unwrap(); - let MeshStreamFrame::Data { payload, .. } = request else { - panic!("expected roster resync request"); + fn finish(&mut self) -> Result<(), MeshError> { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + struct BackpressuredGoodbyeSend { + tx: tokio::sync::mpsc::UnboundedSender, + finished: Arc, + } + + impl StreamSendHalf for BackpressuredGoodbyeSend { + fn send_frame(&mut self, frame: MeshStreamFrame) -> BoxFuture<'_, Result<(), MeshError>> { + if matches!(frame, MeshStreamFrame::Goodbye { .. }) { + return Box::pin(std::future::pending()); + } + let result = self + .tx + .send(frame) + .map_err(|_| MeshError::Transport("peer closed".into())); + Box::pin(async move { result }) + } + + fn finish(&mut self) -> Result<(), MeshError> { + self.finished + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + struct NeverRecv; + + impl StreamRecvHalf for NeverRecv { + fn recv_frame(&mut self) -> BoxFuture<'_, Result, MeshError>> { + Box::pin(std::future::pending()) + } + } + + #[tokio::test] + async fn protected_remote_disconnect_with_backpressured_abort_forces_stream_close_and_completes( + ) { + let finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut stream = MeshStream::new( + Box::new(BackpressuredSend(Arc::clone(&finished))), + Box::new(NeverRecv), + ); + tokio::time::timeout( + std::time::Duration::from_secs(1), + abort_remote_owner( + &mut stream, + fenced_owned_by(rt(1), Uuid::new_v4()), + Uuid::new_v4(), + ), + ) + .await + .expect("bounded abort must complete"); + assert!(finished.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn roster_revision_gap_requests_resync_before_forwarding_new_state() { + let session_id = Uuid::new_v4(); + let fenced = fenced_owned_by(rt(1), session_id); + let (mut owner, mut client) = stream_pair(); + let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::channel(4); + let reader = + tokio::spawn(async move { read_owner_control(&mut client, fenced, 1, &ctrl_tx).await }); + + owner + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RosterDelta { + revision: 3, + joined: Some(RosterEntry { + pubkey: "bob".into(), + peer_index: 7, + }), + left: None, + }) + .unwrap(), + }) + .await + .unwrap(); + + let request = owner.recv_frame().await.unwrap().unwrap(); + let MeshStreamFrame::Data { payload, .. } = request else { + panic!("expected roster resync request"); }; assert_eq!( decode_control(&payload).unwrap(), @@ -2252,6 +3653,12 @@ mod tests { } } + fn owners_for(fenced: FencedHeader) -> Arc { + let owners = Arc::new(HuddleOwnerRegistry::new()); + owners.install_for_test(fenced.session_id, fenced.generation); + owners + } + fn huddle_hello(sender: RuntimeId, fenced: FencedHeader) -> StreamHello { StreamHello { sender, @@ -2262,6 +3669,31 @@ mod tests { } } + #[tokio::test] + async fn control_stream_without_exact_owner_epoch_cannot_start() { + let owner_rt = rt(1); + let from = rt(2); + let fenced = fenced_owned_by(owner_rt, Uuid::new_v4()); + let acceptor = HuddleControlAcceptor::new( + Arc::new(AudioRoomManager::new()), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::new(HuddleOwnerRegistry::new()), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), + ); + let (owner_stream, _client) = stream_pair(); + + let error = tokio::time::timeout( + Duration::from_secs(2), + acceptor.accept_inbound(from, huddle_hello(from, fenced), owner_stream), + ) + .await + .expect("bounded owner-attach wait completes") + .expect_err("a missing exact epoch must fail closed"); + assert!(matches!(error, MeshError::Transport(_))); + } + /// Full accept-side handshake: a structural `Hello`, then a /// community-bearing `RegisterPeer` whose fence passes, yields /// `PeerRegistered`. Exercises the public `MeshStream::new` seam and the @@ -2278,7 +3710,8 @@ mod tests { Arc::new(NullTransport) as Arc, Arc::new(FakeDir::default()), // validate() succeeds by default owner_rt, - Arc::new(HuddleOwnerRegistry::new()), // no owner lease → recv-only + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), ); let (owner_stream, mut client) = stream_pair(); @@ -2315,6 +3748,624 @@ mod tests { served.await.unwrap().unwrap(); } + #[tokio::test] + async fn protected_remote_attachment_is_reserved_activated_and_aborted() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let mut roster = room.subscribe_roster(); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), + ) + .with_authority_verifier( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::allow_for_test(), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let reserved = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected reservation reply, got {other:?}"), + }; + assert!(matches!( + reserved, + HuddleControlMsg::PeerReserved { + admission_id: id, + .. + } if id == admission_id + )); + assert!(room.peer_pubkeys().is_empty()); + assert!( + roster.try_recv().is_err(), + "reservation is not roster-visible" + ); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ActivatePeer { admission_id }).unwrap(), + }) + .await + .unwrap(); + let activated = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected activation reply, got {other:?}"), + }; + assert!(matches!( + activated, + HuddleControlMsg::PeerActivated { + admission_id: id, + .. + } if id == admission_id + )); + assert!( + roster.try_recv().is_err(), + "preparation remains roster-invisible" + ); + assert!(room.peer_pubkeys().is_empty()); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "synthetic-authority".into(), + }) + .unwrap(), + }) + .await + .unwrap(); + let confirmed = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected confirmation reply, got {other:?}"), + }; + assert!(matches!( + confirmed, + HuddleControlMsg::PeerConfirmed { + admission_id: id, + .. + } if id == admission_id + )); + roster + .recv() + .await + .expect("confirmation emits roster delta"); + assert_eq!(room.peer_pubkeys().len(), 1); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::AbortPeer { admission_id }).unwrap(), + }) + .await + .unwrap(); + roster.recv().await.expect("abort emits leave delta"); + assert!(room.peer_pubkeys().is_empty()); + + // Removing the last peer releases this owner epoch. A retry therefore + // resolves a fresh owner and opens a fresh control stream; it must not + // reuse this now-stale stream. + drop(client); + served.await.unwrap().unwrap(); + assert!(room.is_empty(), "abort compensates the active attachment"); + } + + #[tokio::test] + async fn protected_remote_confirmation_revalidates_before_roster_visibility() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let mut roster = room.subscribe_roster(); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), + ) + .with_authority_verifier( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::deny_for_test(), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ActivatePeer { admission_id }).unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "expired-authority".into(), + }) + .unwrap(), + }) + .await + .unwrap(); + + let rejected = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected confirmation rejection, got {other:?}"), + }; + assert!(matches!( + rejected, + HuddleControlMsg::RegisterRejected { + reason: RegisterRejection::Fenced(FenceRejection::NoActiveLease), + .. + } + )); + assert!(room.peer_pubkeys().is_empty()); + assert!( + roster.try_recv().is_err(), + "failed confirmation is invisible" + ); + + client.finish().unwrap(); + drop(client); + served.await.unwrap().unwrap(); + assert!(room.is_empty()); + } + + #[tokio::test] + async fn protected_remote_activation_fails_closed_when_fence_is_lost() { + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let directory = Arc::new(FakeDir::default()); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::clone(&directory) as Arc, + owner_rt, + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + *directory.validate_fails.lock().unwrap() = true; + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ActivatePeer { admission_id }).unwrap(), + }) + .await + .unwrap(); + let rejected = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected activation rejection, got {other:?}"), + }; + assert!(matches!( + rejected, + HuddleControlMsg::RegisterRejected { + reason: RegisterRejection::Fenced(_), + .. + } + )); + assert!(room.is_empty()); + assert!(room.peer_pubkeys().is_empty()); + drop(client); + served.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn protected_remote_attachment_self_expires_without_origin_abort() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let mut roster = room.subscribe_roster(); + let attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); + let gate = Arc::new(AtomicBool::new(true)); + let owners = Arc::new(HuddleOwnerRegistry::new()); + let _lost = owners.install_for_test(session_id, fenced.generation); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::clone(&owners), + Arc::clone(&attachments), + ) + .with_authority_verifier( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::conditional_for_test( + Arc::clone(&gate), + ), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ActivatePeer { admission_id }).unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "conditional-authority".into(), + }) + .unwrap(), + }) + .await + .unwrap(); + let peer_index = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => match decode_control(&payload).unwrap() { + HuddleControlMsg::PeerConfirmed { peer_index, .. } => peer_index, + other => panic!("expected confirmation, got {other:?}"), + }, + other => panic!("expected confirmation data, got {other:?}"), + }; + roster.recv().await.expect("confirmation emits join"); + assert_eq!(room.peer_pubkeys().len(), 1); + + gate.store(false, Ordering::SeqCst); + tokio::time::timeout(std::time::Duration::from_secs(1), roster.recv()) + .await + .expect("authority watcher removes peer promptly") + .expect("authority watcher emits leave"); + assert!(room.peer_pubkeys().is_empty()); + assert!( + owners.lost_for(session_id).is_none(), + "self-expiry releases the empty room owner lease" + ); + + let router = crate::audio::mesh::MeshAudioRouter::with_fence( + rooms, + owner_rt, + Arc::new(crate::audio::mesh::GenerationFloor::new()), + attachments, + ); + assert_eq!( + router.on_media_datagram( + from, + &MeshDatagram { + fenced, + seq: 1, + payload: vec![peer_index, 1, 2], + }, + ), + None + ); + + client.finish().unwrap(); + drop(client); + served.await.unwrap().unwrap(); + } + + async fn assert_protected_remote_revocation_during_fence_validation_never_becomes_visible( + block_on: usize, + ) { + use std::sync::atomic::{AtomicBool, Ordering}; + + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let mut roster = room.subscribe_roster(); + let directory = Arc::new(ConfirmBarrierDir::new(block_on)); + let gate = Arc::new(AtomicBool::new(true)); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::clone(&directory), + owner_rt, + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), + ) + .with_authority_verifier( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::conditional_for_test( + Arc::clone(&gate), + ), + ); + let (owner_stream, mut client) = stream_pair(); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + for message in [ + HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }, + HuddleControlMsg::ActivatePeer { admission_id }, + ] { + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&message).unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + } + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "conditional-authority".into(), + }) + .unwrap(), + }) + .await + .unwrap(); + directory.entered.notified().await; + gate.store(false, Ordering::SeqCst); + directory.release.notify_one(); + + let rejected = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => decode_control(&payload).unwrap(), + other => panic!("expected confirmation rejection, got {other:?}"), + }; + assert!(matches!( + rejected, + HuddleControlMsg::RegisterRejected { + reason: RegisterRejection::Fenced(FenceRejection::NoActiveLease), + .. + } + )); + assert!(room.peer_pubkeys().is_empty()); + assert!(roster.try_recv().is_err(), "revoked peer was never visible"); + client.finish().unwrap(); + drop(client); + served.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn protected_remote_revocation_during_fence_validation_never_becomes_visible() { + assert_protected_remote_revocation_during_fence_validation_never_becomes_visible(3).await; + } + + #[tokio::test] + async fn protected_remote_revocation_during_second_owner_fence_validation_never_becomes_visible( + ) { + assert_protected_remote_revocation_during_fence_validation_never_becomes_visible(4).await; + } + + #[tokio::test] + async fn owner_loss_with_backpressured_goodbye_revokes_media_before_send() { + use std::sync::atomic::Ordering; + + let owner_rt = rt(1); + let from = rt(2); + let session_id = Uuid::new_v4(); + let admission_id = Uuid::new_v4(); + let fenced = fenced_owned_by(owner_rt, session_id); + let rooms = Arc::new(AudioRoomManager::new()); + let room = rooms.get_or_create(community(), session_id); + let attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); + let owners = Arc::new(HuddleOwnerRegistry::new()); + let lost = owners.install_for_test(session_id, fenced.generation); + let acceptor = HuddleControlAcceptor::new( + Arc::clone(&rooms), + Arc::new(NullTransport) as Arc, + Arc::new(FakeDir::default()), + owner_rt, + Arc::clone(&owners), + Arc::clone(&attachments), + ) + .with_authority_verifier( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::conditional_for_test( + Arc::new(std::sync::atomic::AtomicBool::new(true)), + ), + ); + + let (owner_to_client_tx, owner_to_client_rx) = tmpsc::unbounded_channel(); + let (client_to_owner_tx, client_to_owner_rx) = tmpsc::unbounded_channel(); + let finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let owner_stream = MeshStream::new( + Box::new(BackpressuredGoodbyeSend { + tx: owner_to_client_tx, + finished: Arc::clone(&finished), + }), + Box::new(ChanRecv(client_to_owner_rx)), + ); + let mut client = MeshStream::new( + Box::new(ChanSend(client_to_owner_tx)), + Box::new(ChanRecv(owner_to_client_rx)), + ); + let hello = huddle_hello(from, fenced); + let served = + tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + + for message in [ + HuddleControlMsg::ReservePeer { + community_id: *community().as_uuid(), + admission_id, + pubkey: "protected".into(), + protocol_version: 2, + }, + HuddleControlMsg::ActivatePeer { admission_id }, + ] { + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&message).unwrap(), + }) + .await + .unwrap(); + let _ = client.recv_frame().await.unwrap().unwrap(); + } + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::ConfirmPeer { + admission_id, + authority: "conditional-authority".into(), + }) + .unwrap(), + }) + .await + .unwrap(); + let peer_index = match client.recv_frame().await.unwrap().unwrap() { + MeshStreamFrame::Data { payload, .. } => match decode_control(&payload).unwrap() { + HuddleControlMsg::PeerConfirmed { peer_index, .. } => peer_index, + other => panic!("expected confirmation, got {other:?}"), + }, + other => panic!("expected confirmation data, got {other:?}"), + }; + assert_eq!(room.peer_pubkeys().len(), 1); + + lost.cancel(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !room.peer_pubkeys().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("owner loss revokes room peer before goodbye completes"); + + let router = crate::audio::mesh::MeshAudioRouter::with_fence( + rooms, + owner_rt, + Arc::new(crate::audio::mesh::GenerationFloor::new()), + attachments, + ); + assert_eq!( + router.on_media_datagram( + from, + &MeshDatagram { + fenced, + seq: 1, + payload: vec![peer_index, 1, 2], + }, + ), + None + ); + tokio::time::timeout(std::time::Duration::from_secs(1), served) + .await + .expect("bounded goodbye teardown completes") + .unwrap() + .unwrap(); + assert!(finished.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn fresh_local_owner_lease_loss_before_room_activation_never_becomes_visible() { + let directory = FakeDir::default(); + *directory.validate_fails.lock().unwrap() = true; + let room = Arc::new(Room::new(community(), Uuid::new_v4())); + let pending = room + .reserve_peer(Uuid::new_v4(), "local".into(), 2) + .expect("reservation is non-visible"); + + let denied = validate_join_before_visibility( + &directory, + community(), + room.channel_id, + rt(1), + JoinOutcome::LocalOwner { generation: 9 }, + ) + .await; + assert!(denied.is_err()); + drop(pending); + assert!(room.peer_pubkeys().is_empty()); + assert_eq!(room.roster_snapshot().revision, 0); + } + #[tokio::test] async fn abnormal_control_stream_close_fans_out_remote_leave() { let owner_rt = rt(1); @@ -2333,7 +4384,8 @@ mod tests { Arc::new(NullTransport) as Arc, Arc::new(FakeDir::default()), owner_rt, - Arc::new(HuddleOwnerRegistry::new()), + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), ); let (owner_stream, mut client) = stream_pair(); let hello = huddle_hello(from, fenced); @@ -2395,7 +4447,8 @@ mod tests { Arc::new(NullTransport) as Arc, Arc::new(dir), owner_rt, - Arc::new(HuddleOwnerRegistry::new()), // no owner lease → recv-only + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), ); let (owner_stream, mut client) = stream_pair(); @@ -2713,6 +4766,46 @@ mod tests { .expect("release errors must not permanently tombstone the room"); } + #[test] + fn registry_signals_are_bound_to_the_exact_live_epoch() { + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + let lost = registry.install_for_test(session, 7); + + assert!(registry.signals_for(session, 7).is_some()); + assert!(registry.signals_for(session, 6).is_none()); + assert!(registry.signals_for(session, 8).is_none()); + lost.cancel(); + assert!( + registry.signals_for(session, 7).is_none(), + "a cancelled owner epoch is no longer admission authority" + ); + } + + #[tokio::test] + async fn registry_newer_epoch_replaces_stale_local_observation() { + let dir = Arc::new(FakeDir::default()); + let registry = HuddleOwnerRegistry::new(); + let session = Uuid::new_v4(); + + registry.attach( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 4), + ); + let newer = registry.attach_signals( + session, + Arc::clone(&dir) as Arc, + lease_for(session, 5), + ); + + assert!(registry.signals_for(session, 4).is_none()); + assert!(registry.signals_for(session, 5).is_some()); + assert!(!newer.lost.is_cancelled()); + assert!(!newer.draining.is_cancelled()); + await_release_calls(&dir, 1).await; + } + /// `drain` is generation-fenced like `release`, but unlike room-empty it /// also cancels the drain signal so local owner peers and remote control /// streams can rejoin with an explicit draining cause before the renewer @@ -2915,6 +5008,7 @@ mod tests { Arc::new(FakeDir::default()), owner_rt, Arc::clone(&owners), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), ); let (owner_stream, mut client) = stream_pair(); @@ -2922,14 +5016,41 @@ mod tests { let served = tokio::spawn(async move { acceptor.accept_inbound(from, hello, owner_stream).await }); + client + .send_frame(MeshStreamFrame::Data { + fenced, + payload: encode_control(&HuddleControlMsg::RegisterPeer { + community_id: *community().as_uuid(), + pubkey: "remote".into(), + protocol_version: 2, + }) + .unwrap(), + }) + .await + .unwrap(); + let _ = client + .recv_frame() + .await + .expect("registration response") + .expect("registration frame"); + // Owner observes lease loss → proactive Goodbye down the client stream. lost.cancel(); - let frame = tokio::time::timeout(Duration::from_secs(2), client.recv_frame()) - .await - .expect("goodbye arrives") - .unwrap() - .unwrap(); + let frame = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let frame = client + .recv_frame() + .await? + .ok_or_else(|| MeshError::Transport("control stream closed".into()))?; + if matches!(frame, MeshStreamFrame::Goodbye { .. }) { + break Ok::<_, MeshError>(frame); + } + } + }) + .await + .expect("goodbye arrives") + .unwrap(); assert!( matches!( frame, @@ -2954,19 +5075,29 @@ mod tests { let fenced = fenced_owned_by(owner_rt, session_id); let draining = CancellationToken::new(); + let lost = CancellationToken::new(); let acceptor = HuddleControlAcceptor::new( Arc::new(AudioRoomManager::new()), Arc::new(NullTransport) as Arc, Arc::new(FakeDir::default()), owner_rt, - Arc::new(HuddleOwnerRegistry::new()), + owners_for(fenced), + Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()), ); let (owner_stream, mut client) = stream_pair(); let draining_for_loop = draining.clone(); let served = tokio::spawn(async move { acceptor - .serve_control_loop(from, fenced, owner_stream, None, Some(draining_for_loop)) + .serve_control_loop( + from, + fenced, + owner_stream, + HuddleOwnerSignals { + lost, + draining: draining_for_loop, + }, + ) .await }); diff --git a/crates/buzz-relay/src/audio/mesh.rs b/crates/buzz-relay/src/audio/mesh.rs index 1eb62fdcfa..d9e1bda4d7 100644 --- a/crates/buzz-relay/src/audio/mesh.rs +++ b/crates/buzz-relay/src/audio/mesh.rs @@ -46,16 +46,185 @@ //! is guaranteed by the directory's companion INCR counter (session-directory //! lane); this module trusts that and only enforces "reject < known". +use std::collections::HashMap; use std::sync::Arc; use bytes::Bytes; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; use uuid::Uuid; use buzz_relay_mesh::{FencedHeader, MeshDatagram, RelayPeerTransport, RuntimeId}; use super::room::AudioRoomManager; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct OwnerIngressKey { + session_id: Uuid, + generation: u64, + sender: RuntimeId, + peer_index: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct OwnerFanoutKey { + session_id: Uuid, + generation: u64, + owner: RuntimeId, +} + +struct OwnerIngressAttachment { + admission_id: Uuid, + expires_at: u64, +} + +enum MediaAttachmentKind { + OwnerIngress(OwnerIngressKey), + OwnerFanout(OwnerFanoutKey), +} + +/// Live, compensated media attachments established by the reliable huddle +/// control path. A datagram alone can never create an attachment or advance a +/// generation floor. +#[derive(Default)] +pub struct MediaAttachmentRegistry { + owner_ingress: dashmap::DashMap, + owner_fanout: dashmap::DashMap>, +} + +/// Drop guard for one live media attachment. +pub(crate) struct MediaAttachmentGuard { + registry: Arc, + kind: MediaAttachmentKind, + admission_id: Uuid, +} + +impl Drop for MediaAttachmentGuard { + fn drop(&mut self) { + match self.kind { + MediaAttachmentKind::OwnerIngress(key) => { + if self + .registry + .owner_ingress + .get(&key) + .is_some_and(|entry| entry.admission_id == self.admission_id) + { + self.registry.owner_ingress.remove(&key); + } + } + MediaAttachmentKind::OwnerFanout(key) => { + if let Some(mut admissions) = self.registry.owner_fanout.get_mut(&key) { + admissions.remove(&self.admission_id); + let empty = admissions.is_empty(); + drop(admissions); + if empty { + self.registry + .owner_fanout + .remove_if(&key, |_, value| value.is_empty()); + } + } + } + } + } +} + +impl MediaAttachmentRegistry { + /// Register one protected non-owner participant as an authorized media + /// author on the owner pod. The owner-assigned index and authenticated + /// runtime are both part of the key. + pub(crate) fn register_owner_ingress( + self: &Arc, + fenced: FencedHeader, + sender: RuntimeId, + peer_index: u8, + admission_id: Uuid, + expires_at: u64, + ) -> Option { + use dashmap::mapref::entry::Entry; + + let key = OwnerIngressKey { + session_id: fenced.session_id, + generation: fenced.generation, + sender, + peer_index, + }; + match self.owner_ingress.entry(key) { + Entry::Vacant(entry) => { + entry.insert(OwnerIngressAttachment { + admission_id, + expires_at, + }); + } + Entry::Occupied(entry) if entry.get().admission_id == admission_id => {} + Entry::Occupied(_) => return None, + } + Some(MediaAttachmentGuard { + registry: Arc::clone(self), + kind: MediaAttachmentKind::OwnerIngress(key), + admission_id, + }) + } + + /// Register the owner as the only accepted fan-out source for one local + /// protected participant on a non-owner pod. + pub(crate) fn register_owner_fanout( + self: &Arc, + fenced: FencedHeader, + admission_id: Uuid, + expires_at: u64, + ) -> MediaAttachmentGuard { + let key = OwnerFanoutKey { + session_id: fenced.session_id, + generation: fenced.generation, + owner: fenced.owner_runtime_id, + }; + self.owner_fanout + .entry(key) + .or_default() + .insert(admission_id, expires_at); + MediaAttachmentGuard { + registry: Arc::clone(self), + kind: MediaAttachmentKind::OwnerFanout(key), + admission_id, + } + } + + fn authorizes_owner_ingress(&self, key: OwnerIngressKey) -> Option { + let (admission_id, expires_at) = self + .owner_ingress + .get(&key) + .map(|entry| (entry.admission_id, entry.expires_at)) + .unwrap_or((Uuid::nil(), 0)); + let current = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()); + if current.is_none_or(|current| current >= expires_at) { + self.owner_ingress + .remove_if(&key, |_, entry| entry.admission_id == admission_id); + return None; + } + self.owner_ingress + .get(&key) + .filter(|entry| entry.admission_id == admission_id) + .map(|entry| entry.admission_id) + } + + fn authorized_owner_fanout_admissions( + &self, + key: OwnerFanoutKey, + ) -> std::collections::HashSet { + let current = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()); + let Some(mut admissions) = self.owner_fanout.get_mut(&key) else { + return std::collections::HashSet::new(); + }; + admissions.retain(|_, expires_at| current.is_some_and(|current| current < *expires_at)); + admissions.keys().copied().collect() + } +} /// The slice of the session directory that huddle audio needs. /// @@ -160,13 +329,19 @@ pub struct MeshAudioRouter { rooms: Arc, fence: Arc, local_runtime_id: RuntimeId, + attachments: Arc, } impl MeshAudioRouter { /// Construct a router over this pod's rooms, tagged with the local runtime /// identity (used to distinguish owner vs non-owner delivery paths). pub fn new(rooms: Arc, local_runtime_id: RuntimeId) -> Self { - Self::with_fence(rooms, local_runtime_id, Arc::new(GenerationFloor::new())) + Self::with_fence( + rooms, + local_runtime_id, + Arc::new(GenerationFloor::new()), + Arc::new(MediaAttachmentRegistry::default()), + ) } /// Construct a router that enforces an externally owned generation floor. @@ -178,11 +353,13 @@ impl MeshAudioRouter { rooms: Arc, local_runtime_id: RuntimeId, fence: Arc, + attachments: Arc, ) -> Self { Self { rooms, fence, local_runtime_id, + attachments, } } @@ -210,8 +387,55 @@ impl MeshAudioRouter { /// re-fan across the mesh: if we are the owner, cross-pod fan-out happens /// through the remote peers' mesh sinks during `broadcast_frame`, so an /// owner-side inbound datagram only needs local delivery here. - pub fn on_media_datagram(&self, dgram: &MeshDatagram) -> FenceVerdict { + pub fn on_media_datagram(&self, from: RuntimeId, dgram: &MeshDatagram) -> Option { let session_id = dgram.fenced.session_id; + let Some((&author_index, rest)) = dgram.payload.split_first() else { + warn!(%session_id, "empty media datagram payload — dropping"); + return None; + }; + + let owner_ingress = dgram.fenced.owner_runtime_id == self.local_runtime_id; + let mut owner_ingress_admission = None; + let authorized_fanout = if owner_ingress { + let Some(admission_id) = self.attachments.authorizes_owner_ingress(OwnerIngressKey { + session_id, + generation: dgram.fenced.generation, + sender: from, + peer_index: author_index, + }) else { + debug!(%session_id, %from, "dropping media without a live control attachment"); + return None; + }; + owner_ingress_admission = Some(admission_id); + None + } else { + if from != dgram.fenced.owner_runtime_id { + debug!(%session_id, %from, "dropping media from a non-owner runtime"); + return None; + } + let admissions = self + .attachments + .authorized_owner_fanout_admissions(OwnerFanoutKey { + session_id, + generation: dgram.fenced.generation, + owner: from, + }); + if admissions.is_empty() { + debug!(%session_id, %from, "dropping media without a live control attachment"); + return None; + } + Some(admissions) + }; + + let room = self.rooms.get_unambiguous_by_channel(session_id); + if let Some(admission_id) = owner_ingress_admission { + let room = room.as_ref()?; + if !room.is_published_admission(admission_id, author_index) { + debug!(%session_id, %from, "dropping media from an unpublished admission"); + return None; + } + } + let verdict = self.fence.check(session_id, dgram.fenced.generation); if let FenceVerdict::RejectStale { known } = verdict { debug!( @@ -220,21 +444,16 @@ impl MeshAudioRouter { known_generation = known, "dropping stale-generation media datagram (fence)" ); - return verdict; + return Some(verdict); } - let Some(room) = self.rooms.get_unambiguous_by_channel(session_id) else { - // No local room for this session: nothing to deliver to. Not an - // error — membership can race a datagram in flight. An ambiguous - // same-UUID room collision is also dropped because the current - // media envelope has no community label. - return verdict; + let Some(room) = room else { + // Fan-out may race local room construction. The authenticated + // owner generation still advances monotonically, but no output is + // disclosed. Owner-ingress took the stricter path above. + return Some(verdict); }; - let Some((&author_index, rest)) = dgram.payload.split_first() else { - warn!(%session_id, "empty media datagram payload — dropping"); - return verdict; - }; // Reconstruct the exact on-wire frame the local fan-out uses: // [peer_index][v2 header][Opus]. `rest` is [v2 header][Opus]; the // prefix is the author's index. We hand peers the already-prefixed @@ -244,8 +463,13 @@ impl MeshAudioRouter { prefixed.extend_from_slice(rest); let prefixed = prefixed.freeze(); - room.deliver_prefixed(author_index, prefixed); - verdict + match authorized_fanout { + Some(admissions) => { + room.deliver_prefixed_to_admissions(author_index, prefixed, &admissions) + } + None => room.deliver_prefixed(author_index, prefixed), + } + Some(verdict) } } @@ -256,40 +480,167 @@ impl MeshAudioRouter { /// feeds this task, which wraps each frame as a [`MeshDatagram`] and sends it to /// the pod that hosts that participant. Drops on a disconnected/oversize peer — /// realtime audio never blocks fan-out on one slow remote link. -pub fn spawn_remote_peer_sink( +pub(crate) struct RemotePeerSinkGuard { + cancel: CancellationToken, + active: Arc>, +} + +impl RemotePeerSinkGuard { + /// Stop the sink without draining frames already queued for this peer. + /// + /// The mutex makes `close` the local authoritative boundary: once it + /// returns, no later transport send can begin. A send already holding the + /// mutex completes before `close` returns. + pub(crate) fn close(&self) { + self.cancel.cancel(); + if let Ok(mut active) = self.active.lock() { + *active = false; + } + } +} + +impl Drop for RemotePeerSinkGuard { + fn drop(&mut self) { + self.close(); + } +} + +/// One-shot start capability for a prepared remote sink. Protected callers +/// install the guard in their exact expiry effect set before consuming this. +pub(crate) struct RemotePeerSinkStart { + start: Option>, + cancel: CancellationToken, + wake_at: Option, +} + +impl RemotePeerSinkStart { + pub(crate) fn start(mut self) -> bool { + !self.cancel.is_cancelled() + && self + .wake_at + .is_none_or(|wake_at| tokio::time::Instant::now() < wake_at) + && self + .start + .take() + .is_some_and(|start| start.send(()).is_ok()) + } +} + +pub(crate) fn prepare_remote_peer_sink( transport: Arc, to: RuntimeId, fenced: FencedHeader, mut frames: mpsc::Receiver, -) { + wake_at: Option, +) -> (RemotePeerSinkGuard, RemotePeerSinkStart) { + let cancel = CancellationToken::new(); + let active = Arc::new(std::sync::Mutex::new(true)); + let task_cancel = cancel.clone(); + let task_active = Arc::clone(&active); + let (start_tx, start_rx) = oneshot::channel(); tokio::spawn(async move { + tokio::select! { + biased; + _ = task_cancel.cancelled() => return, + started = start_rx => if started.is_err() { return }, + } let mut seq: u64 = 0; - while let Some(frame) = frames.recv().await { + loop { + let frame = tokio::select! { + biased; + _ = task_cancel.cancelled() => break, + frame = frames.recv() => { + let Some(frame) = frame else { break }; + frame + } + }; + if wake_at.is_some_and(|wake_at| tokio::time::Instant::now() >= wake_at) { + task_cancel.cancel(); + break; + } let dgram = MeshDatagram { fenced, seq, payload: frame.to_vec(), }; seq = seq.wrapping_add(1); + let Ok(active) = task_active.lock() else { + break; + }; + if !*active || task_cancel.is_cancelled() { + break; + } if let Err(e) = transport.send_datagram(to, dgram) { - // Disconnected peer or oversize frame: drop and keep going. - // The MTU case is the ship-gate's job to prevent; here we just - // never let one bad link stall the room. debug!(%to, "remote peer datagram send failed: {e}"); } } debug!(%to, "remote peer sink closed"); }); + ( + RemotePeerSinkGuard { + cancel: cancel.clone(), + active, + }, + RemotePeerSinkStart { + start: Some(start_tx), + cancel, + wake_at, + }, + ) +} + +pub(crate) fn spawn_remote_peer_sink( + transport: Arc, + to: RuntimeId, + fenced: FencedHeader, + frames: mpsc::Receiver, +) -> RemotePeerSinkGuard { + let (guard, start) = prepare_remote_peer_sink(transport, to, fenced, frames, None); + let _ = start.start(); + guard } #[cfg(test)] mod tests { use super::*; + #[derive(Default)] + struct RecordingTransport { + sent: std::sync::Mutex>, + } + + impl RelayPeerTransport for RecordingTransport { + fn send_datagram( + &self, + _to: RuntimeId, + dgram: MeshDatagram, + ) -> Result<(), buzz_relay_mesh::MeshError> { + self.sent.lock().expect("recording lock").push(dgram); + Ok(()) + } + + fn open_session_stream( + &self, + _to: RuntimeId, + _hello: buzz_relay_mesh::StreamHello, + ) -> futures_util::future::BoxFuture< + '_, + Result, + > { + Box::pin(async { Err(buzz_relay_mesh::MeshError::Transport("unused".into())) }) + } + + fn set_inbound(&self, _handler: Box) {} + } + fn rt(b: u8) -> RuntimeId { RuntimeId([b; 32]) } + fn community() -> buzz_core::CommunityId { + buzz_core::CommunityId::from_uuid(Uuid::from_u128(1)) + } + fn fenced(session: Uuid, generation: u64) -> FencedHeader { FencedHeader { session_id: session, @@ -298,6 +649,73 @@ mod tests { } } + #[tokio::test(flavor = "current_thread")] + async fn revoked_remote_sink_discards_buffered_fanout_before_transport_send() { + let recording = Arc::new(RecordingTransport::default()); + let transport: Arc = recording.clone(); + let (tx, rx) = mpsc::channel(8); + let guard = spawn_remote_peer_sink(transport, rt(2), fenced(Uuid::new_v4(), 1), rx); + + tx.try_send(Bytes::from_static(b"already-authorized")) + .expect("initial frame queues"); + while recording.sent.lock().expect("recording lock").is_empty() { + tokio::task::yield_now().await; + } + + for _ in 0..8 { + tx.try_send(Bytes::from_static(b"must-be-discarded")) + .expect("revocation backlog queues"); + } + guard.close(); + tokio::task::yield_now().await; + + assert_eq!( + recording.sent.lock().expect("recording lock").len(), + 1, + "closing a revoked sink must discard its buffered fan-out" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn prepared_remote_sink_cannot_emit_before_exact_effect_installation() { + let recording = Arc::new(RecordingTransport::default()); + let transport: Arc = recording.clone(); + let (tx, rx) = mpsc::channel(1); + let (guard, start) = + prepare_remote_peer_sink(transport, rt(2), fenced(Uuid::new_v4(), 1), rx, None); + tx.try_send(Bytes::from_static(b"not-yet-authorized")) + .expect("frame queues"); + tokio::task::yield_now().await; + assert!(recording.sent.lock().expect("recording lock").is_empty()); + + guard.close(); + assert!(!start.start(), "closed prepared sink cannot be started"); + tokio::task::yield_now().await; + assert!(recording.sent.lock().expect("recording lock").is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn prepared_remote_sink_cannot_start_at_exact_authority_deadline() { + let recording = Arc::new(RecordingTransport::default()); + let transport: Arc = recording.clone(); + let (tx, rx) = mpsc::channel(1); + let wake_at = tokio::time::Instant::now() + std::time::Duration::from_millis(250); + let (_guard, start) = prepare_remote_peer_sink( + transport, + rt(2), + fenced(Uuid::new_v4(), 1), + rx, + Some(wake_at), + ); + tx.try_send(Bytes::from_static(b"must-not-cross-deadline")) + .expect("frame queues while hidden"); + + tokio::time::advance(std::time::Duration::from_millis(250)).await; + assert!(!start.start(), "deadline equality fails closed"); + tokio::task::yield_now().await; + assert!(recording.sent.lock().expect("recording lock").is_empty()); + } + #[test] fn fence_accepts_first_and_equal_and_higher() { let f = GenerationFloor::new(); @@ -340,54 +758,243 @@ mod tests { assert_eq!(f.check(s, 3), FenceVerdict::Accept { advanced: false }); } - #[test] - fn router_drops_stale_datagram_without_delivering() { + fn test_router( + rooms: Arc, + local_runtime_id: RuntimeId, + ) -> (MeshAudioRouter, Arc) { + let attachments = Arc::new(MediaAttachmentRegistry::default()); + ( + MeshAudioRouter::with_fence( + rooms, + local_runtime_id, + Arc::new(GenerationFloor::new()), + Arc::clone(&attachments), + ), + attachments, + ) + } + + #[tokio::test] + async fn router_drops_stale_datagram_without_delivering() { let rooms = Arc::new(AudioRoomManager::new()); - let router = MeshAudioRouter::new(Arc::clone(&rooms), rt(1)); + let (router, attachments) = test_router(Arc::clone(&rooms), rt(1)); let s = Uuid::new_v4(); + let fence = fenced(s, 5); + let _attached = attachments.register_owner_fanout(fence, Uuid::new_v4(), u64::MAX); // Establish a floor at generation 5. assert!(matches!( - router.on_media_datagram(&MeshDatagram { - fenced: fenced(s, 5), - seq: 0, - payload: vec![0, 1, 2], - }), - FenceVerdict::Accept { .. } + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![0, 1, 2], + }, + ), + Some(FenceVerdict::Accept { .. }) )); // A stale frame is rejected. + let stale = fenced(s, 4); + let _stale_attached = attachments.register_owner_fanout(stale, Uuid::new_v4(), u64::MAX); assert_eq!( - router.on_media_datagram(&MeshDatagram { - fenced: fenced(s, 4), - seq: 1, - payload: vec![0, 1, 2], - }), - FenceVerdict::RejectStale { known: 5 } + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: stale, + seq: 1, + payload: vec![0, 1, 2], + }, + ), + Some(FenceVerdict::RejectStale { known: 5 }) ); } - #[test] - fn router_tolerates_missing_room_and_empty_payload() { + #[tokio::test] + async fn router_tolerates_missing_room_and_empty_payload() { let rooms = Arc::new(AudioRoomManager::new()); - let router = MeshAudioRouter::new(Arc::clone(&rooms), rt(1)); + let (router, attachments) = test_router(Arc::clone(&rooms), rt(1)); let s = Uuid::new_v4(); + let fence = fenced(s, 1); + let _attached = attachments.register_owner_fanout(fence, Uuid::new_v4(), u64::MAX); // No local room for this session: accepted by fence, no panic. assert!(matches!( - router.on_media_datagram(&MeshDatagram { - fenced: fenced(s, 1), - seq: 0, - payload: vec![7, 8], - }), - FenceVerdict::Accept { .. } + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![7, 8], + }, + ), + Some(FenceVerdict::Accept { .. }) )); - // Empty payload after a valid fence: dropped, no panic. + // Empty payload is dropped before it can alter the fence. let s2 = Uuid::new_v4(); + assert_eq!( + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: fenced(s2, 1), + seq: 0, + payload: vec![], + }, + ), + None + ); + } + + #[tokio::test] + async fn realtime_media_requires_registered_control_attachment() { + let rooms = Arc::new(AudioRoomManager::new()); + let (router, _) = test_router(rooms, rt(1)); + let session = Uuid::new_v4(); + assert_eq!( + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: fenced(session, 99), + seq: 0, + payload: vec![3, 1, 2], + }, + ), + None + ); + assert_eq!( + router.fence().check(session, 1), + FenceVerdict::Accept { advanced: false } + ); + } + + #[tokio::test] + async fn realtime_media_rejects_non_owner_sender_on_ingress() { + let rooms = Arc::new(AudioRoomManager::new()); + let (router, attachments) = test_router(rooms, rt(1)); + let fence = fenced(Uuid::new_v4(), 2); + let _attached = attachments.register_owner_fanout(fence, Uuid::new_v4(), u64::MAX); + assert_eq!( + router.on_media_datagram( + rt(0xBB), + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![4, 1], + }, + ), + None + ); + } + + #[tokio::test] + async fn late_media_after_abort_is_dropped() { + let rooms = Arc::new(AudioRoomManager::new()); + let (router, attachments) = test_router(rooms, rt(1)); + let fence = fenced(Uuid::new_v4(), 3); + let attached = attachments.register_owner_fanout(fence, Uuid::new_v4(), u64::MAX); + drop(attached); + assert_eq!( + router.on_media_datagram( + rt(0xAA), + &MeshDatagram { + fenced: fence, + seq: 1, + payload: vec![5, 1], + }, + ), + None + ); + } + + #[tokio::test] + async fn expired_local_remote_peer_cannot_receive_through_live_sibling() { + let rooms = Arc::new(AudioRoomManager::new()); + let local_runtime = rt(1); + let (router, attachments) = test_router(Arc::clone(&rooms), local_runtime); + let session = Uuid::new_v4(); + let room = rooms.get_or_create(community(), session); + let expired_admission = Uuid::new_v4(); + let live_admission = Uuid::new_v4(); + let expired = room + .reserve_peer(expired_admission, "expired".into(), 2) + .expect("reserve expired peer") + .activate() + .expect("activate expired peer"); + let live = room + .reserve_peer(live_admission, "live".into(), 2) + .expect("reserve live peer") + .activate() + .expect("activate live peer"); + let fence = fenced(session, 4); + let expired_attachment = + attachments.register_owner_fanout(fence, expired_admission, u64::MAX); + let _live_attachment = attachments.register_owner_fanout(fence, live_admission, u64::MAX); + drop(expired_attachment); + assert!(matches!( - router.on_media_datagram(&MeshDatagram { - fenced: fenced(s2, 1), - seq: 0, - payload: vec![], - }), - FenceVerdict::Accept { .. } + router.on_media_datagram( + fence.owner_runtime_id, + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![99, 1, 2, 3], + }, + ), + Some(FenceVerdict::Accept { .. }) )); + let mut expired_rx = expired.2; + let mut live_rx = live.2; + assert!(expired_rx.try_recv().is_err()); + assert_eq!( + live_rx.try_recv().expect("live sibling receives").as_ref(), + &[99, 1, 2, 3] + ); + } + + #[test] + fn expired_owner_ingress_cannot_deliver_or_advance_the_fence() { + let rooms = Arc::new(AudioRoomManager::new()); + let (router, attachments) = test_router(rooms, rt(0xAA)); + let fence = fenced(Uuid::new_v4(), 7); + let sender = rt(1); + let _attached = attachments + .register_owner_ingress(fence, sender, 9, Uuid::new_v4(), 0) + .expect("unique attachment"); + assert_eq!( + router.on_media_datagram( + sender, + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![9, 1, 2], + }, + ), + None + ); + assert_eq!( + router.fence().check(fence.session_id, 1), + FenceVerdict::Accept { advanced: false } + ); + } + + #[test] + fn expired_owner_fanout_cannot_deliver_or_advance_the_fence() { + let rooms = Arc::new(AudioRoomManager::new()); + let (router, attachments) = test_router(rooms, rt(1)); + let fence = fenced(Uuid::new_v4(), 7); + let _attached = attachments.register_owner_fanout(fence, Uuid::new_v4(), 0); + assert_eq!( + router.on_media_datagram( + fence.owner_runtime_id, + &MeshDatagram { + fenced: fence, + seq: 0, + payload: vec![9, 1, 2], + }, + ), + None + ); + assert_eq!( + router.fence().check(fence.session_id, 1), + FenceVerdict::Accept { advanced: false } + ); } } diff --git a/crates/buzz-relay/src/audio/room.rs b/crates/buzz-relay/src/audio/room.rs index c7d95d43c1..2fc592355f 100644 --- a/crates/buzz-relay/src/audio/room.rs +++ b/crates/buzz-relay/src/audio/room.rs @@ -9,10 +9,13 @@ //! `try_send` is used throughout: real-time audio tolerates drops, never queues. use buzz_core::CommunityId; +use buzz_relay_mesh::RuntimeId; use bytes::Bytes; use dashmap::DashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::{broadcast, mpsc}; +use tokio_util::sync::CancellationToken; use uuid::Uuid; /// A connected audio peer. @@ -26,6 +29,56 @@ pub struct AudioPeer { pub ctrl_tx: mpsc::Sender, /// Stable 0-254 index assigned at join; prefixed onto relayed frames. pub peer_index: u8, + /// Durable protected admission attempt, when this peer has one. + admission_id: Option, + /// Absolute protected-authority deadline. Legacy peers have no deadline. + authority_expires_at: Option, + /// Exact monotonic instant at which this protected activation expires. + authority_wake_at: Option, + /// Room-local generation for this exact protected activation. + authority_generation: Option, + /// Effects revoked synchronously before protected roster withdrawal. + protected_effects: Option, + /// Whether this exact protected activation published its client join. + protected_join_published: bool, + /// Owner-side mesh destination. Peers on the same remote runtime share + /// one fan-out copy; the destination pod performs per-admission delivery. + fanout_group: Option<[u8; 32]>, +} + +impl AudioPeer { + fn protected_schedule(&self) -> Option { + Some(ProtectedDeadlineSchedule { + deadline: self.authority_expires_at?, + wake_at: self.authority_wake_at?, + }) + } + + fn authority_is_current(&self) -> bool { + if self.authority_expires_at.is_none() + && self.authority_wake_at.is_none() + && self.protected_effects.is_none() + { + return true; + } + self.protected_schedule() + .is_some_and(ProtectedDeadlineSchedule::is_current) + && self + .protected_effects + .as_ref() + .is_some_and(ProtectedPeerEffects::is_live) + } + + fn is_visible(&self) -> bool { + self.protected_join_published && self.authority_is_current() + } + + fn matches_epoch(&self, epoch: ProtectedPeerEpoch) -> bool { + self.admission_id == Some(epoch.admission_id) + && self.authority_generation == Some(epoch.generation) + && self.authority_expires_at == Some(epoch.deadline) + && self.authority_wake_at == Some(epoch.wake_at) + } } /// Control message for a single peer (separate from audio frames). @@ -36,6 +89,189 @@ pub enum PeerCtrl { Close, } +type EffectRevoker = Box; + +#[derive(Default)] +struct ProtectedPeerEffectsState { + closed: bool, + revokers: Vec, +} + +/// Close-aware effects owned by one protected audio admission. +#[derive(Clone)] +pub(crate) struct ProtectedPeerEffects { + state: Arc>, + cancel: CancellationToken, +} + +impl ProtectedPeerEffects { + pub(crate) fn new(cancel: CancellationToken) -> Self { + Self { + state: Arc::new(std::sync::Mutex::new(ProtectedPeerEffectsState::default())), + cancel, + } + } + + /// Install an exact effect revoker. If expiry already won, execute it now. + pub(crate) fn install_revoker(&self, revoker: F) -> bool + where + F: FnOnce() + Send + 'static, + { + let mut revoker = Some(Box::new(revoker) as EffectRevoker); + let installed = match self.state.lock() { + Ok(mut state) if !state.closed => { + state + .revokers + .push(revoker.take().expect("revoker present")); + true + } + _ => false, + }; + if let Some(revoker) = revoker { + revoker(); + } + installed + } + + pub(crate) fn revoke(&self) { + let revokers = match self.state.lock() { + Ok(mut state) => { + if state.closed { + return; + } + state.closed = true; + std::mem::take(&mut state.revokers) + } + Err(_) => { + self.cancel.cancel(); + return; + } + }; + for revoker in revokers { + revoker(); + } + self.cancel.cancel(); + } + + pub(crate) fn is_live(&self) -> bool { + !self.cancel.is_cancelled() && self.state.lock().is_ok_and(|state| !state.closed) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProtectedPeerEpoch { + community_id: CommunityId, + channel_id: Uuid, + peer_id: Uuid, + admission_id: Uuid, + generation: u64, + deadline: u64, + wake_at: tokio::time::Instant, +} + +struct ActivationCommit { + committed: bool, + protected_epoch: Option, +} + +/// One immutable, conservative schedule for a protected admission deadline. +/// +/// Authority expiries are encoded as whole Unix seconds and denote the start +/// of that second. The monotonic wake instant is therefore derived from a +/// high-resolution wall-clock sample and is never rounded up. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProtectedDeadlineSchedule { + deadline: u64, + wake_at: tokio::time::Instant, +} + +/// Exact mesh owner incarnation authorized to use one protected room. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RoomOwnerEpoch { + pub(crate) owner_runtime_id: RuntimeId, + pub(crate) generation: u64, +} + +impl RoomOwnerEpoch { + pub(crate) const fn new(owner_runtime_id: RuntimeId, generation: u64) -> Self { + Self { + owner_runtime_id, + generation, + } + } +} + +impl ProtectedDeadlineSchedule { + /// Build a production schedule. `coarse_delay` comes from an injected + /// whole-second authorization clock; subtracting one second makes that + /// input conservative before combining it with the high-resolution wall + /// clock. Either source may move closure earlier, never later. + pub(crate) fn new( + deadline: u64, + coarse_delay: Option, + ) -> Result { + let monotonic_now = tokio::time::Instant::now(); + Self::new_anchored(deadline, monotonic_now, coarse_delay) + } + + /// Finish a deadline capture after the caller sampled the monotonic + /// anchor and then consulted an injected/coarse authority clock. + /// + /// Sampling the anchor first is essential: time spent consulting the + /// injected clock must consume authority rather than extend it. + pub(crate) fn new_anchored( + deadline: u64, + monotonic_now: tokio::time::Instant, + coarse_delay: Option, + ) -> Result { + let wall_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| AdmissionError::Ended)?; + Self::from_samples(deadline, monotonic_now, wall_now, coarse_delay) + .ok_or(AdmissionError::Ended) + } + + fn from_samples( + deadline: u64, + monotonic_now: tokio::time::Instant, + wall_now: std::time::Duration, + coarse_delay: Option, + ) -> Option { + let wall_remaining = std::time::Duration::from_secs(deadline).checked_sub(wall_now)?; + if wall_remaining.is_zero() { + return None; + } + let remaining = coarse_delay.map_or(wall_remaining, |delay| { + wall_remaining.min(delay.saturating_sub(std::time::Duration::from_secs(1))) + }); + Some(Self { + deadline, + wake_at: monotonic_now.checked_add(remaining)?, + }) + } + + pub(crate) fn deadline(self) -> u64 { + self.deadline + } + + pub(crate) fn wake_at(self) -> tokio::time::Instant { + self.wake_at + } + + fn is_current(self) -> bool { + let monotonic_now = tokio::time::Instant::now(); + if monotonic_now >= self.wake_at { + return false; + } + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .is_ok_and(|wall_now| wall_now < std::time::Duration::from_secs(self.deadline)) + } +} + +/// A successfully activated peer and its private audio/control receivers. +pub type ActivatedAudioPeer = (Uuid, u8, mpsc::Receiver, mpsc::Receiver); + /// Audio channel capacity per peer: 8 frames = 160ms at 20ms/frame. const AUDIO_CHANNEL_CAPACITY: usize = 8; /// Control channel capacity per peer: 32 slots — must never drop joined/left @@ -127,6 +363,166 @@ struct AdmissionGuard { /// this behavior. pinned_version: Option, roster_revision: u64, + /// Non-visible reservations keyed by the durable admission attempt id. + pending: HashMap, + /// Activated protected attempts. A retry cannot create a second presence. + active: HashMap, + /// Monotonic room-local generation for protected activations. + next_authority_generation: u64, + /// Immutable mesh-owner incarnation for protected use of this room. + owner_epoch: Option, + /// Unique pre-reservation claims. A claim spans every asynchronous step + /// between selecting an owner generation and creating a pending peer. + owner_claims: HashSet, +} + +struct PendingPeerRecord { + peer_id: Uuid, + pubkey: String, + peer_index: u8, + audio_tx: mpsc::Sender, + ctrl_tx: mpsc::Sender, + fanout_group: Option<[u8; 32]>, +} + +/// A non-visible audio attachment reservation. +/// +/// Dropping this value before activation idempotently releases its room index. +pub struct PendingAudioPeer { + room: std::sync::Weak, + admission_id: Uuid, + peer_id: Uuid, + peer_index: u8, + audio_rx: Option>, + ctrl_rx: Option>, + activated: bool, +} + +impl PendingAudioPeer { + /// Owner-assigned peer index reserved for this attempt. + pub fn peer_index(&self) -> u8 { + self.peer_index + } + + /// Atomically make the reserved peer visible and emit its first roster delta. + pub fn activate(self) -> Result { + self.activate_if(|| true)?.ok_or(AdmissionError::Ended) + } + + /// Make the reservation visible only if the synchronous commit predicate + /// is still true while the room admission lock is held. + /// + /// Protected callers use this for the absolute lease deadline and owner + /// epoch after their final asynchronous authority revalidation. A false + /// predicate leaves the reservation pending; `Drop` then releases it + /// without ever inserting a peer or publishing a roster delta. + pub fn activate_if( + mut self, + predicate: F, + ) -> Result, AdmissionError> + where + F: FnOnce() -> bool, + { + let room = self.room.upgrade().ok_or(AdmissionError::Ended)?; + if !room + .activate_pending_if(self.admission_id, self.peer_id, None, None, predicate)? + .committed + { + return Ok(None); + } + self.activated = true; + Ok(Some(( + self.peer_id, + self.peer_index, + self.audio_rx.take().ok_or(AdmissionError::Ended)?, + self.ctrl_rx.take().ok_or(AdmissionError::Ended)?, + ))) + } + + /// Make a protected reservation visible only while its immutable + /// authority deadline is still in the future. + #[cfg(test)] + pub fn activate_protected_if( + self, + expires_at: u64, + predicate: F, + ) -> Result, AdmissionError> + where + F: FnOnce() -> bool, + { + let Ok(schedule) = ProtectedDeadlineSchedule::new(expires_at, None) else { + return Ok(None); + }; + let room = self.room.upgrade().ok_or(AdmissionError::Ended)?; + let result = self.activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + predicate, + )?; + let Some((activated, epoch)) = result else { + return Ok(None); + }; + let pubkey = room + .peers + .get(&activated.0) + .map(|peer| peer.pubkey.clone()) + .ok_or(AdmissionError::Ended)?; + if room + .broadcast_protected_join_if_current(epoch, &pubkey, activated.1) + .is_none() + { + room.remove_protected_epoch(epoch); + return Ok(None); + } + Ok(Some(activated)) + } + + /// Activate with the exact effects that deadline expiry must revoke before + /// the peer is withdrawn from the roster. The V1 deadline is immutable: + /// renewed authority must create a fresh admission/rejoin, so ordinary + /// revalidation cannot silently extend an existing timer. + pub(crate) fn activate_protected_with_effects_if( + mut self, + schedule: ProtectedDeadlineSchedule, + effects: ProtectedPeerEffects, + predicate: F, + ) -> Result, AdmissionError> + where + F: FnOnce() -> bool, + { + let room = self.room.upgrade().ok_or(AdmissionError::Ended)?; + let commit = room.activate_pending_if( + self.admission_id, + self.peer_id, + Some(schedule), + Some(effects), + predicate, + )?; + if !commit.committed { + return Ok(None); + } + self.activated = true; + let activated = ( + self.peer_id, + self.peer_index, + self.audio_rx.take().ok_or(AdmissionError::Ended)?, + self.ctrl_rx.take().ok_or(AdmissionError::Ended)?, + ); + Ok(Some(( + activated, + commit.protected_epoch.ok_or(AdmissionError::Ended)?, + ))) + } +} + +impl Drop for PendingAudioPeer { + fn drop(&mut self) { + if !self.activated { + if let Some(room) = self.room.upgrade() { + room.abort_pending(self.admission_id, self.peer_id); + } + } + } } impl AdmissionGuard { @@ -137,6 +533,11 @@ impl AdmissionGuard { ended: false, pinned_version: None, roster_revision: 0, + pending: HashMap::new(), + active: HashMap::new(), + next_authority_generation: 1, + owner_epoch: None, + owner_claims: HashSet::new(), } } @@ -190,9 +591,10 @@ impl Room { /// Returns `true` if the room is empty (safe to archive + emit 48103). /// Returns `false` if a peer snuck in before we acquired the lock. pub fn mark_ended(&self) -> bool { + self.prune_expired_authority(); if let Ok(mut g) = self.guard.lock() { g.ended = true; - self.peers.is_empty() + self.peers.is_empty() && g.pending.is_empty() && g.owner_claims.is_empty() } else { false } @@ -205,6 +607,55 @@ impl Room { } } + fn mark_ended_if_empty(&self) -> bool { + let Ok(mut guard) = self.guard.lock() else { + return false; + }; + if !self.peers.is_empty() || !guard.pending.is_empty() || !guard.owner_claims.is_empty() { + return false; + } + guard.ended = true; + true + } + + fn owner_epoch(&self) -> Option { + self.guard.lock().ok().and_then(|guard| guard.owner_epoch) + } + + pub(crate) fn matches_owner_epoch(&self, epoch: RoomOwnerEpoch) -> bool { + self.owner_epoch() == Some(epoch) + } + + fn claim_owner_epoch(&self, epoch: RoomOwnerEpoch) -> Result { + let mut guard = self.guard.lock().map_err(|_| AdmissionError::Ended)?; + if guard.ended { + return Err(AdmissionError::Ended); + } + match guard.owner_epoch { + Some(current) if current == epoch => { + let token = Uuid::new_v4(); + guard.owner_claims.insert(token); + Ok(token) + } + Some(_) => Err(AdmissionError::Ended), + None if self.peers.is_empty() && guard.pending.is_empty() => { + guard.owner_epoch = Some(epoch); + let token = Uuid::new_v4(); + guard.owner_claims.insert(token); + Ok(token) + } + None => Err(AdmissionError::Ended), + } + } + + fn release_owner_claim(&self, epoch: RoomOwnerEpoch, token: Uuid) { + if let Ok(mut guard) = self.guard.lock() { + if guard.owner_epoch == Some(epoch) { + guard.owner_claims.remove(&token); + } + } + } + /// Add a peer. Returns `(peer_id, peer_index, audio_rx, ctrl_rx)` on /// success, or an [`AdmissionError`] explaining why the peer was rejected. /// @@ -230,6 +681,28 @@ impl Room { pubkey: String, requested_version: u8, ) -> Result<(Uuid, u8, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + self.add_peer_inner(pubkey, requested_version, None) + } + + /// Add an owner-side representation of a participant hosted by another + /// runtime. Fan-out is grouped by runtime so one source frame produces one + /// mesh datagram per destination pod, regardless of participant count. + pub(crate) fn add_remote_peer( + &self, + pubkey: String, + requested_version: u8, + fanout_group: [u8; 32], + ) -> Result<(Uuid, u8, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + self.add_peer_inner(pubkey, requested_version, Some(fanout_group)) + } + + fn add_peer_inner( + &self, + pubkey: String, + requested_version: u8, + fanout_group: Option<[u8; 32]>, + ) -> Result<(Uuid, u8, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + self.prune_expired_authority(); let mut g = self.guard.lock().map_err( |_| AdmissionError::Ended, /* poisoned ≈ shutting down */ )?; @@ -262,6 +735,13 @@ impl Room { audio_tx, ctrl_tx, peer_index, + admission_id: None, + authority_expires_at: None, + authority_wake_at: None, + authority_generation: None, + protected_effects: None, + protected_join_published: true, + fanout_group, }, ); g.roster_revision = g.roster_revision.wrapping_add(1); @@ -284,6 +764,7 @@ impl Room { requested_version: u8, peer_index: u8, ) -> Result<(Uuid, mpsc::Receiver, mpsc::Receiver), AdmissionError> { + self.prune_expired_authority(); let mut g = self.guard.lock().map_err(|_| AdmissionError::Ended)?; if g.ended { return Err(AdmissionError::Ended); @@ -320,6 +801,13 @@ impl Room { audio_tx, ctrl_tx, peer_index, + admission_id: None, + authority_expires_at: None, + authority_wake_at: None, + authority_generation: None, + protected_effects: None, + protected_join_published: true, + fanout_group: None, }, ); g.roster_revision = g.roster_revision.wrapping_add(1); @@ -333,24 +821,333 @@ impl Room { Ok((peer_id, audio_rx, ctrl_rx)) } + /// Reserve a local peer without exposing it to roster or media fan-out. + pub fn reserve_peer( + self: &Arc, + admission_id: Uuid, + pubkey: String, + requested_version: u8, + ) -> Result { + self.reserve_peer_inner(admission_id, pubkey, requested_version, None, None) + } + + /// Reserve an owner-side remote participant without making it visible. + pub(crate) fn reserve_remote_peer( + self: &Arc, + admission_id: Uuid, + pubkey: String, + requested_version: u8, + fanout_group: [u8; 32], + ) -> Result { + self.reserve_peer_inner( + admission_id, + pubkey, + requested_version, + None, + Some(fanout_group), + ) + } + + /// Reserve an ingress peer at the index already chosen by the room owner. + pub fn reserve_peer_at_index( + self: &Arc, + admission_id: Uuid, + pubkey: String, + requested_version: u8, + peer_index: u8, + ) -> Result { + self.reserve_peer_inner( + admission_id, + pubkey, + requested_version, + Some(peer_index), + None, + ) + } + + fn reserve_peer_inner( + self: &Arc, + admission_id: Uuid, + pubkey: String, + requested_version: u8, + requested_index: Option, + fanout_group: Option<[u8; 32]>, + ) -> Result { + self.prune_expired_authority(); + let mut guard = self.guard.lock().map_err(|_| AdmissionError::Ended)?; + if guard.ended { + return Err(AdmissionError::Ended); + } + if self.peers.len() + guard.pending.len() >= MAX_PEERS_PER_ROOM + || guard.pending.contains_key(&admission_id) + || guard.active.contains_key(&admission_id) + { + return Err(AdmissionError::Full); + } + if let Some(pinned) = guard.pinned_version { + if pinned != requested_version { + return Err(AdmissionError::VersionMismatch { + pinned, + requested: requested_version, + }); + } + } + let peer_index = match requested_index { + Some(index) + if !self.peers.iter().any(|peer| peer.peer_index == index) + && !guard + .pending + .values() + .any(|pending| pending.peer_index == index) => + { + guard.free.retain(|candidate| *candidate != index); + if index >= guard.next_fresh { + guard.next_fresh = index.saturating_add(1); + } + index + } + Some(_) => return Err(AdmissionError::Full), + None => guard.alloc().ok_or(AdmissionError::Full)?, + }; + guard.pinned_version.get_or_insert(requested_version); + let peer_id = Uuid::new_v4(); + let (audio_tx, audio_rx) = mpsc::channel(AUDIO_CHANNEL_CAPACITY); + let (ctrl_tx, ctrl_rx) = mpsc::channel(CTRL_CHANNEL_CAPACITY); + guard.pending.insert( + admission_id, + PendingPeerRecord { + peer_id, + pubkey, + peer_index, + audio_tx, + ctrl_tx, + fanout_group, + }, + ); + Ok(PendingAudioPeer { + room: Arc::downgrade(self), + admission_id, + peer_id, + peer_index, + audio_rx: Some(audio_rx), + ctrl_rx: Some(ctrl_rx), + activated: false, + }) + } + + fn activate_pending_if( + self: &Arc, + admission_id: Uuid, + peer_id: Uuid, + authority_schedule: Option, + protected_effects: Option, + predicate: F, + ) -> Result + where + F: FnOnce() -> bool, + { + let authority_expires_at = authority_schedule.map(ProtectedDeadlineSchedule::deadline); + let authority_wake_at = authority_schedule.map(ProtectedDeadlineSchedule::wake_at); + let timer = authority_schedule + .map(|schedule| { + let handle = + tokio::runtime::Handle::try_current().map_err(|_| AdmissionError::Ended)?; + Ok::<_, AdmissionError>((handle, schedule.wake_at())) + }) + .transpose()?; + let mut guard = self.guard.lock().map_err(|_| AdmissionError::Ended)?; + if guard.ended { + return Err(AdmissionError::Ended); + } + if !predicate() || authority_schedule.is_some_and(|schedule| !schedule.is_current()) { + return Ok(ActivationCommit { + committed: false, + protected_epoch: None, + }); + } + let pending = guard + .pending + .remove(&admission_id) + .filter(|pending| pending.peer_id == peer_id) + .ok_or(AdmissionError::Ended)?; + let authority_generation = authority_expires_at.map(|_| { + let generation = guard.next_authority_generation; + guard.next_authority_generation = + guard.next_authority_generation.wrapping_add(1).max(1); + generation + }); + self.peers.insert( + peer_id, + AudioPeer { + pubkey: pending.pubkey.clone(), + audio_tx: pending.audio_tx, + ctrl_tx: pending.ctrl_tx, + peer_index: pending.peer_index, + admission_id: Some(admission_id), + authority_expires_at, + authority_wake_at, + authority_generation, + protected_effects, + protected_join_published: authority_schedule.is_none(), + fanout_group: pending.fanout_group, + }, + ); + guard.active.insert(admission_id, peer_id); + let protected_epoch = if let (Some(deadline), Some(generation)) = + (authority_expires_at, authority_generation) + { + let epoch = ProtectedPeerEpoch { + community_id: self.community_id, + channel_id: self.channel_id, + peer_id, + admission_id, + generation, + deadline, + wake_at: authority_wake_at.ok_or(AdmissionError::Ended)?, + }; + if let Some((handle, wake_at)) = timer { + let room = Arc::downgrade(self); + handle.spawn(async move { + tokio::time::sleep_until(wake_at).await; + if let Some(room) = room.upgrade() { + room.expire_protected_epoch(epoch); + } + }); + } + Some(epoch) + } else { + None + }; + // Protected activation is deliberately hidden. Publication performs + // the final synchronous authority check and is the sole linearization + // point for roster, snapshot, media, and control visibility. + if authority_schedule.is_none() { + guard.roster_revision = guard.roster_revision.wrapping_add(1); + let _ = self.roster_tx.send(RosterDelta { + revision: guard.roster_revision, + joined: Some(RosterPeer { + pubkey: pending.pubkey, + peer_index: pending.peer_index, + }), + left: None, + }); + } + Ok(ActivationCommit { + committed: true, + protected_epoch, + }) + } + + fn abort_pending(&self, admission_id: Uuid, peer_id: Uuid) { + let Ok(mut guard) = self.guard.lock() else { + return; + }; + if let Some(pending) = guard + .pending + .remove(&admission_id) + .filter(|pending| pending.peer_id == peer_id) + { + guard.release(pending.peer_index); + if self.peers.is_empty() && guard.pending.is_empty() && guard.owner_claims.is_empty() { + guard.pinned_version = None; + } + } + } + + /// Expire only the exact activation that scheduled this timer. Reused + /// peer ids, admission ids, and later deadlines cannot be evicted by a + /// stale task because all epoch fields must still match under the room + /// admission lock. + fn expire_protected_epoch(&self, epoch: ProtectedPeerEpoch) -> bool { + if self.community_id != epoch.community_id || self.channel_id != epoch.channel_id { + return false; + } + let Ok(mut guard) = self.guard.lock() else { + return false; + }; + let matches = self + .peers + .get(&epoch.peer_id) + .is_some_and(|peer| peer.matches_epoch(epoch)); + if !matches { + return false; + } + let Some((_, peer)) = self.peers.remove(&epoch.peer_id) else { + return false; + }; + + // Revoke every exact media/control effect before publishing that this + // peer is gone. Late effect registrations observe closed and compensate + // synchronously instead of resurrecting the admission. + let _ = peer.ctrl_tx.try_send(PeerCtrl::Close); + if let Some(effects) = peer.protected_effects.as_ref() { + effects.revoke(); + } + guard.active.remove(&epoch.admission_id); + guard.release(peer.peer_index); + if peer.protected_join_published { + guard.roster_revision = guard.roster_revision.wrapping_add(1); + let left = RosterPeer { + pubkey: peer.pubkey, + peer_index: peer.peer_index, + }; + let _ = self.roster_tx.send(RosterDelta { + revision: guard.roster_revision, + joined: None, + left: Some(left.clone()), + }); + let message = serde_json::json!({ + "type": "left", + "pubkey": left.pubkey, + "peer_index": left.peer_index, + }) + .to_string(); + for remaining in self.peers.iter().filter(|peer| peer.is_visible()) { + let _ = remaining.ctrl_tx.try_send(PeerCtrl::Json(message.clone())); + } + } + if self.peers.is_empty() && guard.pending.is_empty() && guard.owner_claims.is_empty() { + guard.pinned_version = None; + } + true + } + + /// Retire exactly one protected admission generation. Stale cleanup can + /// never remove a renewed or rejoined peer that reused an admission id. + pub(crate) fn remove_protected_epoch(&self, epoch: ProtectedPeerEpoch) -> bool { + self.expire_protected_epoch(epoch) + } + /// Remove a peer and recycle its index. - pub fn remove_peer(&self, peer_id: Uuid) { + pub fn remove_peer(&self, peer_id: Uuid) -> bool { let Ok(mut g) = self.guard.lock() else { - return; + return false; }; if let Some((_, peer)) = self.peers.remove(&peer_id) { + let _ = peer.ctrl_tx.try_send(PeerCtrl::Close); + if let Some(effects) = peer.protected_effects.as_ref() { + effects.revoke(); + } + if let Some(admission_id) = peer.admission_id { + g.active.remove(&admission_id); + } g.release(peer.peer_index); - g.roster_revision = g.roster_revision.wrapping_add(1); - let delta = RosterDelta { - revision: g.roster_revision, - joined: None, - left: Some(RosterPeer { - pubkey: peer.pubkey, - peer_index: peer.peer_index, - }), - }; - let _ = self.roster_tx.send(delta); + if peer.protected_join_published { + g.roster_revision = g.roster_revision.wrapping_add(1); + let delta = RosterDelta { + revision: g.roster_revision, + joined: None, + left: Some(RosterPeer { + pubkey: peer.pubkey, + peer_index: peer.peer_index, + }), + }; + let _ = self.roster_tx.send(delta); + } drop(g); + true + } else { + false } } @@ -362,27 +1159,42 @@ impl Room { pub fn remove_peer_and_check_ended(&self, peer_id: Uuid) -> Option<(u8, bool)> { let mut g = self.guard.lock().ok()?; let (_, peer) = self.peers.remove(&peer_id)?; + let _ = peer.ctrl_tx.try_send(PeerCtrl::Close); + if let Some(effects) = peer.protected_effects.as_ref() { + effects.revoke(); + } + if let Some(admission_id) = peer.admission_id { + g.active.remove(&admission_id); + } let peer_index = peer.peer_index; g.release(peer_index); - g.roster_revision = g.roster_revision.wrapping_add(1); - let delta = RosterDelta { - revision: g.roster_revision, - joined: None, - left: Some(RosterPeer { - pubkey: peer.pubkey, - peer_index, - }), - }; + let delta = peer.protected_join_published.then(|| { + g.roster_revision = g.roster_revision.wrapping_add(1); + RosterDelta { + revision: g.roster_revision, + joined: None, + left: Some(RosterPeer { + pubkey: peer.pubkey, + peer_index, + }), + } + }); // Only the first task to see empty + !ended wins the auto-end. // This prevents duplicate archive/48103 when two peers disconnect // simultaneously and both see is_empty() == true. - let should_end = if !g.ended && self.peers.is_empty() { + let should_end = if !g.ended + && self.peers.is_empty() + && g.pending.is_empty() + && g.owner_claims.is_empty() + { g.ended = true; true } else { false }; - let _ = self.roster_tx.send(delta); + if let Some(delta) = delta { + let _ = self.roster_tx.send(delta); + } drop(g); Some((peer_index, should_end)) } @@ -391,9 +1203,11 @@ impl Room { /// Prepends the sender's `peer_index` as a 1-byte prefix. /// Drops on full buffer — real-time audio never queues. pub fn broadcast_frame(&self, sender_id: Uuid, frame: Bytes) { + self.prune_expired_authority(); let sender_index = match self.peers.get(&sender_id) { - Some(p) => p.peer_index, + Some(p) if p.is_visible() => p.peer_index, None => return, + Some(_) => return, }; // Prepend peer_index as 1-byte header. @@ -402,10 +1216,16 @@ impl Room { prefixed.extend_from_slice(&frame); let prefixed = prefixed.freeze(); + let mut delivered_groups = std::collections::HashSet::new(); for entry in self.peers.iter() { - if *entry.key() == sender_id { + if *entry.key() == sender_id || !entry.is_visible() { continue; } + if let Some(group) = entry.fanout_group { + if !delivered_groups.insert(group) { + continue; + } + } let _ = entry.audio_tx.try_send(prefixed.clone()); } } @@ -420,22 +1240,44 @@ impl Room { /// round-tripped owner→back-to-their-pod from hearing themselves. Drops on /// full — real-time audio never queues. pub fn deliver_prefixed(&self, author_index: u8, prefixed: Bytes) { + self.prune_expired_authority(); for entry in self.peers.iter() { - if entry.peer_index == author_index { + if entry.peer_index == author_index || !entry.is_visible() { continue; } let _ = entry.audio_tx.try_send(prefixed.clone()); } } - /// Send a JSON control message to all peers via the control channel. - /// Separate from audio so control is never starved by audio backpressure. - /// Control messages (joined/left) are state-bearing — the client's - /// peer_index→pubkey map depends on receiving every one. The channel is - /// sized generously (32 slots) so drops should never happen in practice; + /// Deliver owner fan-out only to local peers whose exact protected + /// admission (or legacy peer identity) still has a live media attachment. + pub fn deliver_prefixed_to_admissions( + &self, + author_index: u8, + prefixed: Bytes, + admissions: &std::collections::HashSet, + ) { + self.prune_expired_authority(); + for entry in self.peers.iter() { + if entry.peer_index == author_index || !entry.is_visible() { + continue; + } + let recipient = entry.admission_id.unwrap_or(*entry.key()); + if admissions.contains(&recipient) { + let _ = entry.audio_tx.try_send(prefixed.clone()); + } + } + } + + /// Send a JSON control message to all peers via the control channel. + /// Separate from audio so control is never starved by audio backpressure. + /// Control messages (joined/left) are state-bearing — the client's + /// peer_index→pubkey map depends on receiving every one. The channel is + /// sized generously (32 slots) so drops should never happen in practice; /// if they do, we log a warning so the issue is visible. pub fn broadcast_control(&self, json: String) { - for entry in self.peers.iter() { + self.prune_expired_authority(); + for entry in self.peers.iter().filter(|peer| peer.is_visible()) { if entry .ctrl_tx .try_send(PeerCtrl::Json(json.clone())) @@ -449,6 +1291,155 @@ impl Room { } } + /// Publish one protected join only if that exact peer is still present + /// and its absolute authority deadline remains current at emission. + /// + /// The returned roster is captured under the same admission lock as the + /// control fan-out, so a caller cannot acknowledge a peer that pruning + /// removed between a stale prebuilt `joined` message and its reply. + pub(crate) fn publish_protected_join_if_current( + &self, + epoch: ProtectedPeerEpoch, + expected_pubkey: &str, + expected_index: u8, + publish: F, + ) -> Option<(RosterSnapshot, T)> + where + F: FnOnce(&str, u8, &RosterSnapshot) -> Option, + { + self.prune_expired_authority(); + let mut guard = self.guard.lock().ok()?; + let peer = self.peers.get(&epoch.peer_id)?; + if peer.pubkey != expected_pubkey + || peer.peer_index != expected_index + || !peer.matches_epoch(epoch) + || peer.protected_join_published + || !(ProtectedDeadlineSchedule { + deadline: epoch.deadline, + wake_at: epoch.wake_at, + }) + .is_current() + || !peer + .protected_effects + .as_ref() + .is_some_and(ProtectedPeerEffects::is_live) + { + return None; + } + let pubkey = peer.pubkey.clone(); + let peer_index = peer.peer_index; + drop(peer); + let next_revision = guard.roster_revision.wrapping_add(1); + let mut peers = self + .peers + .iter() + .filter(|entry| entry.is_visible()) + .map(|entry| RosterPeer { + pubkey: entry.pubkey.clone(), + peer_index: entry.peer_index, + }) + .collect::>(); + peers.push(RosterPeer { + pubkey: pubkey.clone(), + peer_index, + }); + peers.sort_by_key(|entry| entry.peer_index); + let snapshot = RosterSnapshot { + revision: next_revision, + peers, + }; + // The candidate snapshot may take time to construct. Recheck the exact + // schedule immediately before the non-awaiting sink publication. + let peer = self.peers.get(&epoch.peer_id)?; + if !peer.matches_epoch(epoch) + || peer.protected_join_published + || !peer.authority_is_current() + { + return None; + } + drop(peer); + let output = publish(&pubkey, peer_index, &snapshot)?; + let mut peer = self.peers.get_mut(&epoch.peer_id)?; + if !peer.matches_epoch(epoch) || peer.protected_join_published { + return None; + } + peer.protected_join_published = true; + drop(peer); + guard.roster_revision = next_revision; + let _ = self.roster_tx.send(RosterDelta { + revision: guard.roster_revision, + joined: Some(RosterPeer { + pubkey: pubkey.clone(), + peer_index, + }), + left: None, + }); + drop(guard); + Some((snapshot, output)) + } + + /// Publish one exact protected join to room control queues. + pub(crate) fn broadcast_protected_join_if_current( + &self, + epoch: ProtectedPeerEpoch, + expected_pubkey: &str, + expected_index: u8, + ) -> Option { + self.publish_protected_join_if_current( + epoch, + expected_pubkey, + expected_index, + |pubkey, peer_index, _snapshot| { + let joined = serde_json::json!({ + "type": "joined", + "pubkey": pubkey, + "peer_index": peer_index, + "peers": [{"pubkey": expected_pubkey, "peer_index": expected_index}], + }) + .to_string(); + for entry in self + .peers + .iter() + .filter(|peer| peer.is_visible() || *peer.key() == epoch.peer_id) + { + if entry + .ctrl_tx + .try_send(PeerCtrl::Json(joined.clone())) + .is_err() + { + tracing::warn!( + peer_id = %entry.key(), + "control channel full — dropped state-bearing message (peer map may desync)" + ); + } + } + Some(()) + }, + ) + .map(|(snapshot, ())| snapshot) + } + + /// Whether one exact protected admission generation is currently visible + /// and authorized for media/control effects. + pub(crate) fn is_protected_epoch_current(&self, epoch: ProtectedPeerEpoch) -> bool { + self.prune_expired_authority(); + self.guard.lock().is_ok_and(|_| { + self.peers + .get(&epoch.peer_id) + .is_some_and(|peer| peer.matches_epoch(epoch) && peer.is_visible()) + }) + } + + /// Bind owner-side ingress to the exact published admission/index pair. + pub(crate) fn is_published_admission(&self, admission_id: Uuid, peer_index: u8) -> bool { + self.prune_expired_authority(); + self.peers.iter().any(|peer| { + peer.admission_id == Some(admission_id) + && peer.peer_index == peer_index + && peer.is_visible() + }) + } + /// Subscribe to ordered roster mutations. A lagged receiver must call /// [`Self::roster_snapshot`] and continue from that snapshot's revision. pub fn subscribe_roster(&self) -> broadcast::Receiver { @@ -459,10 +1450,12 @@ impl Room { /// admission/removal. Subscribe before calling this to close the /// snapshot-to-delta race; stale deltas at or below `revision` are ignored. pub fn roster_snapshot(&self) -> RosterSnapshot { + self.prune_expired_authority(); let g = self.guard.lock().unwrap_or_else(|e| e.into_inner()); let mut peers = self .peers .iter() + .filter(|entry| entry.is_visible()) .map(|e| RosterPeer { pubkey: e.pubkey.clone(), peer_index: e.peer_index, @@ -477,23 +1470,93 @@ impl Room { /// All `(pubkey, peer_index)` pairs in the room. pub fn peer_pubkeys(&self) -> Vec<(String, u8)> { + self.prune_expired_authority(); self.peers .iter() + .filter(|entry| entry.is_visible()) .map(|e| (e.pubkey.clone(), e.peer_index)) .collect() } /// True if no peers remain in the room. pub fn is_empty(&self) -> bool { + self.prune_expired_authority(); self.peers.is_empty() + && self + .guard + .lock() + .map(|guard| guard.pending.is_empty() && guard.owner_claims.is_empty()) + .unwrap_or(false) + } + + fn prune_expired_authority(&self) { + self.prune_expired_authority_at(unix_time_seconds()); + } + + fn prune_expired_authority_at(&self, now: u64) { + let expired = self + .peers + .iter() + .filter_map(|peer| { + let deadline = peer.authority_expires_at?; + let wake_at = peer.authority_wake_at?; + (deadline <= now || !peer.authority_is_current()).then_some(ProtectedPeerEpoch { + community_id: self.community_id, + channel_id: self.channel_id, + peer_id: *peer.key(), + admission_id: peer.admission_id?, + generation: peer.authority_generation?, + deadline, + wake_at, + }) + }) + .collect::>(); + for epoch in expired { + self.expire_protected_epoch(epoch); + } } } +fn unix_time_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + /// Global registry of active audio rooms. pub struct AudioRoomManager { rooms: DashMap<(CommunityId, Uuid), Arc>, } +/// RAII claim for the asynchronous gap before a protected reservation exists. +/// The unique token prevents room retirement and owner-generation reuse until +/// the caller either creates a pending peer or exits. +pub(crate) struct ProtectedRoomClaim { + room: Arc, + epoch: RoomOwnerEpoch, + token: Uuid, +} + +impl ProtectedRoomClaim { + pub(crate) fn room(&self) -> Arc { + Arc::clone(&self.room) + } +} + +impl std::ops::Deref for ProtectedRoomClaim { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.room + } +} + +impl Drop for ProtectedRoomClaim { + fn drop(&mut self) { + self.room.release_owner_claim(self.epoch, self.token); + } +} + impl AudioRoomManager { /// Create an empty room manager. pub fn new() -> Self { @@ -514,6 +1577,41 @@ impl AudioRoomManager { .clone() } + /// Claim a room for one exact mesh owner incarnation. A different epoch + /// may replace only a quiescent room, and the old owner is released while + /// that exact incarnation is still fenced from new admission. + pub(crate) fn get_or_create_for_owner( + &self, + community_id: CommunityId, + channel_id: Uuid, + epoch: RoomOwnerEpoch, + release_old_owner: F, + ) -> Result + where + F: Fn(RoomOwnerEpoch), + { + loop { + let room = self.get_or_create(community_id, channel_id); + if let Ok(token) = room.claim_owner_epoch(epoch) { + return Ok(ProtectedRoomClaim { room, epoch, token }); + } + let Some(old_epoch) = room.owner_epoch() else { + return Err(AdmissionError::Ended); + }; + if old_epoch == epoch + || !self.retire_exact_owner_if_empty( + community_id, + channel_id, + &room, + old_epoch, + || release_old_owner(old_epoch), + ) + { + return Err(AdmissionError::Ended); + } + } + } + /// Look up an existing community-local room without creating one. pub fn get(&self, community_id: CommunityId, channel_id: Uuid) -> Option> { self.rooms @@ -546,6 +1644,60 @@ impl AudioRoomManager { .remove_if(&(community_id, channel_id), |_, room| room.is_empty()) .is_some() } + + /// Retire one exact room incarnation and run its owner-release fence while + /// the map key is still exclusively held. A concurrent rejoin therefore + /// either lands in the old room before it is found empty, or creates a new + /// room only after the old generation has been released. + #[cfg(test)] + pub(crate) fn retire_exact_if_empty( + &self, + community_id: CommunityId, + channel_id: Uuid, + expected: &Arc, + release_owner: F, + ) -> bool + where + F: FnOnce(), + { + let mut release_owner = Some(release_owner); + self.rooms + .remove_if(&(community_id, channel_id), |_, current| { + if !Arc::ptr_eq(current, expected) || !current.mark_ended_if_empty() { + return false; + } + release_owner.take().expect("release called once")(); + true + }) + .is_some() + } + + /// Retire only the exact room pointer and exact owner incarnation. + pub(crate) fn retire_exact_owner_if_empty( + &self, + community_id: CommunityId, + channel_id: Uuid, + expected: &Arc, + expected_epoch: RoomOwnerEpoch, + release_owner: F, + ) -> bool + where + F: FnOnce(), + { + let mut release_owner = Some(release_owner); + self.rooms + .remove_if(&(community_id, channel_id), |_, current| { + if !Arc::ptr_eq(current, expected) + || current.owner_epoch() != Some(expected_epoch) + || !current.mark_ended_if_empty() + { + return false; + } + release_owner.take().expect("release called once")(); + true + }) + .is_some() + } } impl Default for AudioRoomManager { @@ -608,6 +1760,619 @@ mod tests { ); } + #[tokio::test] + async fn expired_protected_peer_is_withdrawn_before_roster_or_media_emission() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let (peer_id, peer_index, _audio, mut control) = pending + .activate_protected_if(unix_time_seconds() + 3_600, || true) + .expect("activation result") + .expect("protected peer activates before deadline"); + let (_legacy_id, ..) = room.add_peer("legacy".into(), 2).expect("legacy peer"); + let mut deltas = room.subscribe_roster(); + + room.prune_expired_authority_at(u64::MAX); + + assert!(!room.peers.contains_key(&peer_id)); + assert!(room + .roster_snapshot() + .peers + .iter() + .all(|peer| peer.peer_index != peer_index)); + let left = deltas.try_recv().expect("expiry publishes a leave delta"); + assert_eq!(left.left.map(|peer| peer.peer_index), Some(peer_index)); + assert!( + std::iter::from_fn(|| control.try_recv().ok()) + .any(|message| matches!(message, PeerCtrl::Close)), + "expiry queues exact close after any prior join control" + ); + room.prune_expired_authority_at(u64::MAX); + assert!(deltas.try_recv().is_err(), "retry is idempotent"); + } + + #[tokio::test(start_paused = true)] + async fn idle_protected_peer_closes_at_exact_deadline_without_room_activity() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let deadline = unix_time_seconds() + 10; + let mut deltas = room.subscribe_roster(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let (peer_id, peer_index, _audio, mut control) = pending + .activate_protected_if(deadline, || true) + .expect("activation result") + .expect("protected peer activates before deadline"); + let joined = deltas.try_recv().expect("activation publishes join"); + assert_eq!(joined.joined.map(|peer| peer.peer_index), Some(peer_index)); + + tokio::time::advance(std::time::Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + assert!( + !room.peers.contains_key(&peer_id), + "idle protected peer must be removed without a later room call" + ); + assert!( + !room + .guard + .lock() + .expect("room guard") + .active + .contains_key(&admission_id), + "expiry must remove the exact active admission" + ); + let left = deltas.try_recv().expect("expiry publishes one leave"); + assert_eq!(left.left.map(|peer| peer.peer_index), Some(peer_index)); + assert!( + std::iter::from_fn(|| control.try_recv().ok()) + .any(|message| matches!(message, PeerCtrl::Close)), + "expiry closes the exact control channel after prior join control" + ); + assert!(deltas.try_recv().is_err(), "expiry is idempotent"); + } + + #[tokio::test(start_paused = true)] + async fn expiry_revokes_effects_before_publishing_roster_withdrawal() { + let room = Arc::new(fresh_room()); + let effects = ProtectedPeerEffects::new(CancellationToken::new()); + let revoked = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = Arc::clone(&revoked); + assert!(effects.install_revoker(move || { + observed.store(true, std::sync::atomic::Ordering::SeqCst); + })); + let mut deltas = room.subscribe_roster(); + let pending = room + .reserve_peer(Uuid::new_v4(), "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 5, None) + .expect("future protected deadline"); + let ((_peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if(schedule, effects, || true) + .expect("activation") + .expect("visible before expiry"); + assert!(room + .broadcast_protected_join_if_current(epoch, "protected", peer_index) + .is_some()); + let _ = deltas.try_recv().expect("join"); + + tokio::time::advance(std::time::Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + assert!(revoked.load(std::sync::atomic::Ordering::SeqCst)); + assert!(deltas.try_recv().expect("leave").left.is_some()); + } + + #[test] + fn effect_registration_after_expiry_compensates_immediately() { + let effects = ProtectedPeerEffects::new(CancellationToken::new()); + let first = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first_observed = Arc::clone(&first); + assert!(effects.install_revoker(move || { + first_observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + effects.revoke(); + effects.revoke(); + assert_eq!(first.load(std::sync::atomic::Ordering::SeqCst), 1); + let compensated = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = Arc::clone(&compensated); + + assert!(!effects.install_revoker(move || { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + assert_eq!(compensated.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn stale_timer_cannot_evict_a_rejoined_admission() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let first = room + .reserve_peer(admission_id, "first".into(), 2) + .expect("reserve first") + .activate_protected_if(unix_time_seconds() + 5, || true) + .expect("activate first") + .expect("first visible"); + room.remove_peer(first.0); + let second = room + .reserve_peer(admission_id, "second".into(), 2) + .expect("reserve rejoin") + .activate_protected_if(unix_time_seconds() + 20, || true) + .expect("activate rejoin") + .expect("rejoin visible"); + + tokio::time::advance(std::time::Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + assert!(room.peers.contains_key(&second.0)); + assert_eq!(room.roster_snapshot().peers[0].pubkey, "second"); + } + + #[test] + fn subsecond_deadline_delay_must_not_round_up() { + let wall_now = std::time::Duration::new(100, 900_000_000); + let deadline = 101_u64; + + let monotonic_now = tokio::time::Instant::now(); + let schedule = + ProtectedDeadlineSchedule::from_samples(deadline, monotonic_now, wall_now, None) + .expect("future deadline"); + let delay = schedule.wake_at().duration_since(monotonic_now); + + assert_eq!(delay, std::time::Duration::from_millis(100)); + } + + #[tokio::test(start_paused = true)] + async fn elapsed_monotonic_deadline_rejects_activation_without_timer_poll() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let monotonic_now = tokio::time::Instant::now(); + let wall_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall clock after epoch"); + let schedule = ProtectedDeadlineSchedule::from_samples( + unix_time_seconds() + 3_600, + monotonic_now, + wall_now, + Some(std::time::Duration::ZERO), + ) + .expect("absolute deadline remains in the future"); + + let activated = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result"); + + assert!( + activated.is_none(), + "wake_at equality is expired even when the timer has not polled" + ); + assert!(room.peers.is_empty()); + assert_eq!(room.roster_snapshot().revision, 0); + } + + #[tokio::test(start_paused = true)] + async fn elapsed_monotonic_deadline_rejects_publication_without_timer_poll() { + let room = Arc::new(fresh_room()); + let pending = room + .reserve_peer(Uuid::new_v4(), "protected".into(), 2) + .expect("reserve protected peer"); + let monotonic_now = tokio::time::Instant::now(); + let wall_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("wall clock after epoch"); + let schedule = ProtectedDeadlineSchedule::from_samples( + unix_time_seconds() + 3_600, + monotonic_now, + wall_now, + Some(std::time::Duration::from_secs(2)), + ) + .expect("future schedule"); + let ((_peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("activation precedes wake_at"); + + tokio::time::advance(std::time::Duration::from_secs(1)).await; + // Deliberately do not yield: the asynchronous expiry task must not be + // the authority fence for publication. + assert!(room + .broadcast_protected_join_if_current(epoch, "protected", peer_index) + .is_none()); + assert_eq!(room.roster_snapshot().revision, 0); + } + + #[tokio::test] + async fn protected_activation_is_hidden_from_every_room_projection() { + let room = Arc::new(fresh_room()); + let (observer_id, _observer_index, mut observer_audio, _observer_control) = + room.add_peer("observer".into(), 2).expect("observer joins"); + let mut roster = room.subscribe_roster(); + let pending = room + .reserve_peer(Uuid::new_v4(), "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 60, None) + .expect("future deadline"); + let ((hidden_id, _hidden_index, mut hidden_audio, mut hidden_control), _epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("hidden activation succeeds"); + + assert_eq!( + room.roster_snapshot().peers, + vec![RosterPeer { + pubkey: "observer".into(), + peer_index: 0, + }] + ); + assert_eq!(room.roster_snapshot().revision, 1); + assert_eq!(room.peer_pubkeys(), vec![("observer".into(), 0)]); + assert!( + roster.try_recv().is_err(), + "hidden activation emits no delta" + ); + + room.broadcast_frame(observer_id, Bytes::from_static(b"observer-frame")); + assert!( + hidden_audio.try_recv().is_err(), + "hidden peer receives no media" + ); + room.broadcast_frame(hidden_id, Bytes::from_static(b"hidden-frame")); + assert!( + observer_audio.try_recv().is_err(), + "hidden peer cannot author visible media" + ); + room.broadcast_control("control-probe".into()); + assert!( + hidden_control.try_recv().is_err(), + "hidden peer receives no control fan-out" + ); + } + + #[tokio::test] + async fn failed_publication_sink_leaves_protected_peer_hidden() { + let room = Arc::new(fresh_room()); + let mut deltas = room.subscribe_roster(); + let pending = room + .reserve_peer(Uuid::new_v4(), "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 60, None) + .expect("future deadline"); + let ((_peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("hidden activation succeeds"); + + assert!(room + .publish_protected_join_if_current( + epoch, + "protected", + peer_index, + |_pubkey, _peer_index, _snapshot| None::<()>, + ) + .is_none()); + assert!(room.peer_pubkeys().is_empty()); + assert_eq!(room.roster_snapshot().revision, 0); + assert!(deltas.try_recv().is_err()); + assert!(room.remove_protected_epoch(epoch)); + assert!(deltas.try_recv().is_err(), "hidden cleanup emits no left"); + } + + #[test] + fn in_flight_owner_claim_blocks_retirement_and_generation_reuse() { + let manager = AudioRoomManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let first = RoomOwnerEpoch::new(RuntimeId([1; 32]), 7); + let second = RoomOwnerEpoch::new(RuntimeId([1; 32]), 8); + let claimed = manager + .get_or_create_for_owner(community, channel, first, |_| {}) + .expect("first owner claim"); + + assert!(matches!( + manager.get_or_create_for_owner(community, channel, second, |_| {}), + Err(AdmissionError::Ended) + )); + assert!( + !manager.retire_exact_owner_if_empty(community, channel, &claimed, first, || {}), + "the exact room cannot retire while its pre-reservation claim is live" + ); + assert_eq!( + manager.get(community, channel).unwrap().owner_epoch(), + Some(first) + ); + } + + #[tokio::test] + async fn unpublished_expiry_emits_neither_left_nor_stale_join() { + let room = Arc::new(fresh_room()); + let (_observer_id, _observer_index, _observer_audio, mut observer_control) = + room.add_peer("observer".into(), 2).expect("observer joins"); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 60, None) + .expect("future deadline"); + let ((peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("protected peer activates"); + + assert!(room.expire_protected_epoch(epoch)); + assert!(room + .broadcast_protected_join_if_current(epoch, "protected", peer_index) + .is_none()); + assert!(!room.peers.contains_key(&peer_id)); + + let messages = std::iter::from_fn(|| observer_control.try_recv().ok()) + .filter_map(|message| match message { + PeerCtrl::Json(json) => Some(json), + PeerCtrl::Close => None, + }) + .collect::>(); + assert!( + messages.is_empty(), + "a hidden peer has no visible lifecycle" + ); + } + + #[tokio::test] + async fn protected_join_and_expiry_linearize_in_join_then_left_order() { + let room = Arc::new(fresh_room()); + let (_observer_id, _observer_index, _observer_audio, mut observer_control) = + room.add_peer("observer".into(), 2).expect("observer joins"); + let pending = room + .reserve_peer(Uuid::new_v4(), "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 60, None) + .expect("future deadline"); + let ((_peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("protected peer activates"); + + assert!(room + .broadcast_protected_join_if_current(epoch, "protected", peer_index) + .is_some()); + assert!(room.expire_protected_epoch(epoch)); + + let messages = std::iter::from_fn(|| observer_control.try_recv().ok()) + .filter_map(|message| match message { + PeerCtrl::Json(json) => Some(json), + PeerCtrl::Close => None, + }) + .collect::>(); + let joined = messages + .iter() + .position(|message| message.contains("\"joined\"")) + .expect("join publishes first"); + let left = messages + .iter() + .position(|message| message.contains("\"left\"")) + .expect("expiry publishes second"); + assert!(joined < left); + } + + #[test] + fn stale_owner_retirement_cannot_orphan_a_new_room_generation() { + let manager = AudioRoomManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let first = RoomOwnerEpoch::new(RuntimeId([1; 32]), 7); + let second = RoomOwnerEpoch::new(RuntimeId([1; 32]), 8); + + let old_claim = manager + .get_or_create_for_owner(community, channel, first, |_| {}) + .expect("first owner claim"); + let old = old_claim.room(); + drop(old_claim); + let replacement_claim = manager + .get_or_create_for_owner(community, channel, second, |_| {}) + .expect("quiescent generation replacement"); + let replacement = replacement_claim.room(); + assert!(!Arc::ptr_eq(&old, &replacement)); + + assert!( + !manager.retire_exact_owner_if_empty(community, channel, &old, first, || panic!( + "stale owner must not be released" + ),) + ); + assert!(replacement.add_peer("rejoin".into(), 2).is_ok()); + } + + #[test] + fn owner_generation_cannot_change_while_room_has_a_live_claim() { + let manager = AudioRoomManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let first = RoomOwnerEpoch::new(RuntimeId([1; 32]), 7); + let second = RoomOwnerEpoch::new(RuntimeId([1; 32]), 8); + let room = manager + .get_or_create_for_owner(community, channel, first, |_| {}) + .expect("first owner claim"); + let _peer = room.add_peer("active".into(), 2).expect("live peer"); + + assert!(matches!( + manager.get_or_create_for_owner(community, channel, second, |_| {}), + Err(AdmissionError::Ended) + )); + assert_eq!( + manager.get(community, channel).unwrap().owner_epoch(), + Some(first) + ); + } + + #[test] + fn exact_empty_retirement_releases_before_new_room_incarnation() { + let manager = AudioRoomManager::new(); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let old = manager.get_or_create(community, channel); + let released = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = Arc::clone(&released); + + assert!(manager.retire_exact_if_empty(community, channel, &old, || { + observed.store(true, std::sync::atomic::Ordering::SeqCst); + })); + assert!(released.load(std::sync::atomic::Ordering::SeqCst)); + assert!(matches!( + old.add_peer("stale".into(), 2), + Err(AdmissionError::Ended) + )); + + let new = manager.get_or_create(community, channel); + assert!(!Arc::ptr_eq(&old, &new)); + assert!(new.add_peer("rejoin".into(), 2).is_ok()); + } + + #[test] + fn concurrent_rejoin_waits_for_generation_fenced_owner_release() { + let manager = Arc::new(AudioRoomManager::new()); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let old = manager.get_or_create(community, channel); + let (release_entered_tx, release_entered_rx) = std::sync::mpsc::channel(); + let (allow_release_tx, allow_release_rx) = std::sync::mpsc::channel(); + let retire_manager = Arc::clone(&manager); + let retired_room = Arc::clone(&old); + let retire = std::thread::spawn(move || { + retire_manager.retire_exact_if_empty(community, channel, &retired_room, || { + release_entered_tx.send(()).expect("report release fence"); + allow_release_rx.recv().expect("allow owner release"); + }) + }); + release_entered_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("retirement reaches owner release"); + + let (rejoined_tx, rejoined_rx) = std::sync::mpsc::channel(); + let rejoin_manager = Arc::clone(&manager); + let rejoin = std::thread::spawn(move || { + let room = rejoin_manager.get_or_create(community, channel); + rejoined_tx.send(room).expect("return new room"); + }); + assert!( + rejoined_rx + .recv_timeout(std::time::Duration::from_millis(25)) + .is_err(), + "new room cannot publish before the old owner generation is released" + ); + + allow_release_tx.send(()).expect("finish owner release"); + assert!(retire.join().expect("retirement thread")); + let new = rejoined_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("rejoin completes after release"); + rejoin.join().expect("rejoin thread"); + assert!(!Arc::ptr_eq(&old, &new)); + } + + #[test] + fn expired_pending_authority_never_becomes_visible() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + assert!(pending + .activate_protected_if(unix_time_seconds(), || true) + .expect("activation result") + .is_none()); + assert!(room.roster_snapshot().peers.is_empty()); + assert_eq!(room.roster_snapshot().revision, 0); + } + + #[tokio::test] + async fn expired_protected_peer_cannot_publish_joined_or_be_acknowledged() { + let room = Arc::new(fresh_room()); + let (_legacy_id, _legacy_index, _legacy_audio, mut legacy_control) = + room.add_peer("legacy".into(), 2).expect("legacy peer"); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let schedule = ProtectedDeadlineSchedule::new(unix_time_seconds() + 3_600, None) + .expect("future schedule"); + let ((peer_id, peer_index, _audio, _control), epoch) = pending + .activate_protected_with_effects_if( + schedule, + ProtectedPeerEffects::new(CancellationToken::new()), + || true, + ) + .expect("activation result") + .expect("protected peer activates before deadline"); + let mut peer = room.peers.get_mut(&peer_id).expect("protected peer"); + peer.authority_expires_at = Some(0); + drop(peer); + + assert!(room + .broadcast_protected_join_if_current(epoch, "protected", peer_index) + .is_none()); + assert!(!room.peers.contains_key(&peer_id)); + let messages = std::iter::from_fn(|| legacy_control.try_recv().ok()) + .filter_map(|message| match message { + PeerCtrl::Json(json) => Some(json), + PeerCtrl::Close => None, + }) + .collect::>(); + assert!( + messages.is_empty(), + "an unpublished peer has no client history" + ); + } + + #[test] + fn failed_durable_visibility_gate_leaves_reservation_unpublished() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let reservation = room + .reserve_peer(admission_id, "protected".into(), 2) + .expect("reserve protected peer"); + let mut roster = room.subscribe_roster(); + + let injected_visibility_result: Result<(), &'static str> = Err("injected failure"); + if injected_visibility_result.is_ok() { + let _ = reservation.activate_protected_if(unix_time_seconds() + 3_600, || true); + } else { + drop(reservation); + } + + assert!(room.peers.is_empty()); + assert!(room.roster_snapshot().peers.is_empty()); + assert_eq!(room.roster_snapshot().revision, 0); + assert!(roster.try_recv().is_err()); + } + /// First peer's `requested_version` becomes the room's pin; later peers /// requesting the same version are admitted normally. #[test] @@ -791,4 +2556,143 @@ mod tests { // And the room state must be unchanged. assert_eq!(room.peers.len(), MAX_PEERS_PER_ROOM); } + + #[test] + fn protected_reservation_is_invisible_until_activation() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let mut deltas = room.subscribe_roster(); + + let pending = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("reservation succeeds"); + assert!(room.peer_pubkeys().is_empty()); + assert!(deltas.try_recv().is_err()); + assert!( + !room.is_empty(), + "pending attachment prevents room eviction" + ); + + let (peer_id, peer_index, ..) = pending.activate().expect("activation succeeds"); + assert_eq!(room.peer_pubkeys(), vec![("alice".into(), peer_index)]); + let delta = deltas.try_recv().expect("activation publishes one delta"); + assert_eq!(delta.joined.unwrap().peer_index, peer_index); + room.remove_peer(peer_id); + } + + #[test] + fn failed_commit_predicate_never_publishes_protected_presence() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let mut deltas = room.subscribe_roster(); + let pending = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("reservation succeeds"); + let reserved_index = pending.peer_index(); + + assert!(pending + .activate_if(|| false) + .expect("predicate denial is not a room failure") + .is_none()); + assert!(room.peer_pubkeys().is_empty()); + assert!(deltas.try_recv().is_err()); + + let retry = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("denied activation releases the attempt"); + assert_eq!(retry.peer_index(), reserved_index); + } + + #[test] + fn dropped_reservation_is_compensated_and_retryable() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let first = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("first reservation succeeds"); + let first_index = first.peer_index(); + + assert!( + room.reserve_peer(admission_id, "alice".into(), 2).is_err(), + "a live attempt cannot reserve twice" + ); + drop(first); + let retry = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("aborted attempt can retry"); + assert_eq!(retry.peer_index(), first_index); + } + + #[test] + fn active_attempt_cannot_create_duplicate_presence() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer(admission_id, "alice".into(), 2) + .expect("reservation succeeds"); + let (peer_id, ..) = pending.activate().expect("activation succeeds"); + + assert!( + room.reserve_peer(admission_id, "alice".into(), 2).is_err(), + "active attempt cannot attach twice" + ); + room.remove_peer(peer_id); + assert!( + room.reserve_peer(admission_id, "alice".into(), 2).is_ok(), + "disconnect removes the ephemeral attempt marker" + ); + } + + #[test] + fn owner_assigned_pending_index_is_reserved_and_released() { + let room = Arc::new(fresh_room()); + let admission_id = Uuid::new_v4(); + let pending = room + .reserve_peer_at_index(admission_id, "remote".into(), 2, 7) + .expect("owner-assigned reservation succeeds"); + assert!( + room.reserve_peer_at_index(Uuid::new_v4(), "other".into(), 2, 7) + .is_err(), + "pending indices cannot collide" + ); + drop(pending); + assert_eq!( + room.reserve_peer_at_index(Uuid::new_v4(), "retry".into(), 2, 7) + .expect("aborted index can be reused") + .peer_index(), + 7 + ); + } + + #[test] + fn owner_fanout_emits_once_per_remote_runtime() { + let room = fresh_room(); + let (sender, ..) = room.add_peer("owner-local".into(), 2).unwrap(); + let remote_runtime = [0x42; 32]; + let (first_id, _, mut first_rx, _) = room + .add_remote_peer("remote-a".into(), 2, remote_runtime) + .unwrap(); + let (_, _, mut second_rx, _) = room + .add_remote_peer("remote-b".into(), 2, remote_runtime) + .unwrap(); + + room.broadcast_frame(sender, Bytes::from_static(b"frame-one")); + let first_count = usize::from(first_rx.try_recv().is_ok()); + let second_count = usize::from(second_rx.try_recv().is_ok()); + assert_eq!(first_count + second_count, 1); + + room.broadcast_frame(first_id, Bytes::from_static(b"remote-frame")); + assert!( + first_rx.try_recv().is_err(), + "the author never hears itself" + ); + assert!( + second_rx.try_recv().is_ok(), + "a same-runtime sibling still causes one pod fan-out" + ); + + room.remove_peer(first_id); + room.broadcast_frame(sender, Bytes::from_static(b"frame-two")); + assert!(second_rx.try_recv().is_ok()); + } } diff --git a/migrations/0031_protected_object_publications.sql b/migrations/0031_protected_object_publications.sql new file mode 100644 index 0000000000..99213b9342 --- /dev/null +++ b/migrations/0031_protected_object_publications.sql @@ -0,0 +1,50 @@ +-- PostgreSQL-authoritative visibility for protected object-store content. +-- Immutable objects may be staged before these rows commit; without a current +-- publication row they are not visible in Enforce. + +CREATE TABLE git_repo_publications ( + community_id UUID NOT NULL, + repo_id TEXT NOT NULL, + owner_pubkey TEXT NOT NULL, + manifest_sha256 TEXT NOT NULL CHECK ( + manifest_sha256 ~ '^[0-9a-f]{64}$' + ), + publication_version BIGINT NOT NULL CHECK (publication_version > 0), + state TEXT NOT NULL DEFAULT 'active' CHECK ( + state IN ('active', 'unpublished') + ), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, repo_id), + FOREIGN KEY (community_id, repo_id) + REFERENCES git_repo_names (community_id, repo_id) +); + +CREATE TABLE media_publications ( + community_id UUID NOT NULL REFERENCES communities(id), + sha256 TEXT NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + object_key TEXT NOT NULL CHECK ( + length(object_key) > 0 AND length(object_key) <= 512 + ), + extension TEXT NOT NULL CHECK (extension ~ '^[a-z0-9]{1,8}$'), + mime_type TEXT NOT NULL CHECK ( + length(mime_type) > 0 AND length(mime_type) <= 255 + ), + object_size BIGINT NOT NULL CHECK (object_size >= 0), + metadata JSONB NOT NULL CHECK ( + octet_length(metadata::text) <= 16384 + ), + thumbnail_key TEXT CHECK ( + thumbnail_key IS NULL OR (length(thumbnail_key) > 0 AND length(thumbnail_key) <= 512) + ), + publication_version BIGINT NOT NULL DEFAULT 1 CHECK (publication_version > 0), + state TEXT NOT NULL DEFAULT 'active' CHECK ( + state IN ('active', 'unpublished') + ), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, sha256) +); + +CREATE INDEX idx_media_publications_state + ON media_publications (community_id, state); diff --git a/migrations/0032_audio_session_admissions.sql b/migrations/0032_audio_session_admissions.sql new file mode 100644 index 0000000000..6db8a8b3ad --- /dev/null +++ b/migrations/0032_audio_session_admissions.sql @@ -0,0 +1,22 @@ +-- Durable authority boundary for protected audio sessions. +-- +-- The row records a bounded existing-member admission. It does not create +-- membership and cannot outlive the finalized access lease. + +CREATE TABLE audio_session_admissions ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + admission_id UUID NOT NULL, + channel_id UUID NOT NULL, + pubkey BYTEA NOT NULL CHECK (octet_length(pubkey) = 32), + lease_expires_at TIMESTAMPTZ NOT NULL, + admitted_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, admission_id), + CONSTRAINT audio_session_admissions_channel_fk + FOREIGN KEY (community_id, channel_id) + REFERENCES channels(community_id, id) ON DELETE CASCADE, + CONSTRAINT audio_session_admissions_positive_lease + CHECK (lease_expires_at > admitted_at) +); + +CREATE INDEX idx_audio_session_admissions_active + ON audio_session_admissions (community_id, channel_id, lease_expires_at); diff --git a/migrations/0033_protected_object_authority.sql b/migrations/0033_protected_object_authority.sql new file mode 100644 index 0000000000..43bbbef54a --- /dev/null +++ b/migrations/0033_protected_object_authority.sql @@ -0,0 +1,28 @@ +-- Monotonic per-community authority for protected Git and media visibility. +-- +-- Missing rows are treated as legacy only by migration-aware binaries. Once a +-- row enters `importing`, writes to the legacy visibility object are fenced and +-- the state can advance only to `postgresql`. + +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + surface TEXT NOT NULL CHECK (surface IN ('git', 'media')), + state TEXT NOT NULL CHECK (state IN ('legacy', 'importing', 'postgresql')), + generation BIGINT NOT NULL CHECK (generation > 0), + imported_objects BIGINT NOT NULL DEFAULT 0 CHECK (imported_objects >= 0), + inventory_sha256 TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, surface), + CHECK (inventory_sha256 IS NULL OR inventory_sha256 ~ '^[0-9a-f]{64}$'), + CHECK ( + (state = 'legacy' AND started_at IS NULL AND completed_at IS NULL) + OR (state = 'importing' AND started_at IS NOT NULL AND completed_at IS NULL) + OR (state = 'postgresql' AND started_at IS NOT NULL AND completed_at IS NOT NULL + AND inventory_sha256 IS NOT NULL) + ) +); + +CREATE INDEX idx_protected_object_authority_state + ON protected_object_authority (state, community_id, surface); diff --git a/migrations/0034_git_publication_origin.sql b/migrations/0034_git_publication_origin.sql new file mode 100644 index 0000000000..885dbd7e2d --- /dev/null +++ b/migrations/0034_git_publication_origin.sql @@ -0,0 +1,7 @@ +-- Distinguish a legacy reservation whose pointer must exist from an Enforce +-- announcement that intentionally starts unpublished and takes its first push +-- only after PostgreSQL cutover. Existing reservations are conservatively +-- classified as legacy; migration must fail on a missing legacy pointer. +ALTER TABLE git_repo_names + ADD COLUMN publication_origin TEXT NOT NULL DEFAULT 'legacy' + CHECK (publication_origin IN ('legacy', 'protected_unpublished')); diff --git a/migrations/0035_audio_admission_lifecycle.sql b/migrations/0035_audio_admission_lifecycle.sql new file mode 100644 index 0000000000..68cf49bd31 --- /dev/null +++ b/migrations/0035_audio_admission_lifecycle.sql @@ -0,0 +1,39 @@ +-- Durable lifecycle for PostgreSQL-authorized audio attachment attempts. +-- +-- These rows authorize bounded attempts; they never assert live presence or +-- create membership. Existing one-shot receipts are closed during upgrade. + +ALTER TABLE audio_session_admissions + ADD COLUMN state TEXT, + ADD COLUMN state_version BIGINT, + ADD COLUMN updated_at TIMESTAMPTZ, + ADD COLUMN activated_at TIMESTAMPTZ, + ADD COLUMN aborted_at TIMESTAMPTZ, + ADD COLUMN finished_at TIMESTAMPTZ, + ADD COLUMN failure_code TEXT; + +UPDATE audio_session_admissions +SET state = 'aborted', + state_version = 1, + updated_at = admitted_at, + aborted_at = admitted_at, + failure_code = 'upgrade_closed'; + +ALTER TABLE audio_session_admissions + ALTER COLUMN state SET DEFAULT 'reserved', + ALTER COLUMN state SET NOT NULL, + ALTER COLUMN state_version SET DEFAULT 1, + ALTER COLUMN state_version SET NOT NULL, + ALTER COLUMN updated_at SET DEFAULT clock_timestamp(), + ALTER COLUMN updated_at SET NOT NULL, + ADD CONSTRAINT audio_session_admissions_state + CHECK (state IN ('reserved', 'active', 'aborted', 'finished')), + ADD CONSTRAINT audio_session_admissions_state_version + CHECK (state_version > 0), + ADD CONSTRAINT audio_session_admissions_failure_code + CHECK (failure_code IS NULL OR + (length(failure_code) BETWEEN 1 AND 64 AND + failure_code ~ '^[a-z0-9_]+$')); + +CREATE INDEX idx_audio_session_admissions_reconcile + ON audio_session_admissions (state, updated_at, lease_expires_at); diff --git a/migrations/0036_protected_community_lifecycle_guard.sql b/migrations/0036_protected_community_lifecycle_guard.sql new file mode 100644 index 0000000000..810e372e94 --- /dev/null +++ b/migrations/0036_protected_community_lifecycle_guard.sql @@ -0,0 +1,31 @@ +-- O4 has no authority model for community archive, restore, or physical +-- teardown. Keep those transitions unavailable for activated Enforce domains +-- so a stale database restore cannot resurrect a protected community. Off and +-- observational communities retain their existing lifecycle behavior. + +CREATE FUNCTION deny_unwitnessed_protected_community_lifecycle() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM authorization_invalidation_domains + WHERE community_id = OLD.id + ) THEN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'protected community lifecycle requires a separately witnessed authority model' + USING ERRCODE = 'check_violation'; + END IF; + IF NEW.archived_at IS DISTINCT FROM OLD.archived_at THEN + RAISE EXCEPTION 'protected community lifecycle requires a separately witnessed authority model' + USING ERRCODE = 'check_violation'; + END IF; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END +$$; + +CREATE TRIGGER protected_community_lifecycle_guard + BEFORE UPDATE OF archived_at OR DELETE ON communities + FOR EACH ROW EXECUTE FUNCTION deny_unwitnessed_protected_community_lifecycle(); diff --git a/migrations/0037_protected_domain_marker_guard.sql b/migrations/0037_protected_domain_marker_guard.sql new file mode 100644 index 0000000000..76d9803b6b --- /dev/null +++ b/migrations/0037_protected_domain_marker_guard.sql @@ -0,0 +1,16 @@ +-- Protected-domain activation is a one-way V1 cutover. Removing the marker +-- would disable database lifecycle fencing and let an unaware release treat +-- the domain as legacy. O4 has no witnessed downgrade operation, so direct +-- marker removal remains unavailable. + +CREATE FUNCTION deny_protected_domain_marker_delete() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'protected domain activation cannot be removed without a separately witnessed authority model' + USING ERRCODE = 'check_violation'; +END +$$; + +CREATE TRIGGER protected_domain_marker_delete_guard + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION deny_protected_domain_marker_delete(); diff --git a/migrations/0038_audio_cleanup_requests.sql b/migrations/0038_audio_cleanup_requests.sql new file mode 100644 index 0000000000..f0e4cf24fa --- /dev/null +++ b/migrations/0038_audio_cleanup_requests.sql @@ -0,0 +1,11 @@ +-- Durable disconnect/cancellation intent for live audio attempts. A request is +-- witnessed before ephemeral cleanup begins, allowing another relay to finish +-- compensation immediately when the initiating process loses the race. + +ALTER TABLE audio_session_admissions + ADD COLUMN cleanup_requested_at TIMESTAMPTZ; + +CREATE INDEX idx_audio_session_admissions_cleanup_requested + ON audio_session_admissions (community_id, cleanup_requested_at) + WHERE cleanup_requested_at IS NOT NULL + AND state IN ('reserved', 'active'); diff --git a/migrations/0039_git_policy_authority_epoch.sql b/migrations/0039_git_policy_authority_epoch.sql new file mode 100644 index 0000000000..dcd9c8b3cc --- /dev/null +++ b/migrations/0039_git_policy_authority_epoch.sql @@ -0,0 +1,18 @@ +-- Kind 30617 is live Git authorization policy. Its replacement or deletion +-- must advance the independently witnessed PostgreSQL authority vector so a +-- stale restore cannot revive an earlier, more permissive policy. + +CREATE TRIGGER git_policy_insert_authority_epoch + AFTER INSERT ON events + FOR EACH ROW WHEN (NEW.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_policy_update_authority_epoch + AFTER UPDATE ON events + FOR EACH ROW WHEN (OLD.kind = 30617 OR NEW.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_policy_delete_authority_epoch + AFTER DELETE ON events + FOR EACH ROW WHEN (OLD.kind = 30617) + EXECUTE FUNCTION advance_authorization_authority_epoch(); diff --git a/migrations/0040_audio_admission_visibility.sql b/migrations/0040_audio_admission_visibility.sql new file mode 100644 index 0000000000..7eaaba11bc --- /dev/null +++ b/migrations/0040_audio_admission_visibility.sql @@ -0,0 +1,57 @@ +-- Durable proof that an authorized audio attempt became peer-visible. +-- `active` remains authorization for an attempt, never proof of presence. + +ALTER TABLE audio_session_admissions + ADD COLUMN visibility_observed_at TIMESTAMPTZ; + +ALTER TABLE audio_session_admissions + DROP CONSTRAINT audio_session_admissions_state, + DROP CONSTRAINT audio_session_admissions_claim; + +ALTER TABLE audio_session_admissions + ADD CONSTRAINT audio_session_admissions_state + CHECK (state IN ('reserved', 'active', 'visible', 'aborted', 'finished')), + ADD CONSTRAINT audio_session_admissions_claim CHECK ( + (state = 'reserved' + AND claimant_id IS NOT NULL + AND attachment_generation = 0 + AND claim_expires_at IS NOT NULL) + OR + (state IN ('active', 'visible', 'finished') + AND claimant_id IS NOT NULL + AND attachment_generation > 0 + AND claim_expires_at IS NOT NULL) + OR + (state = 'aborted') + ), + ADD CONSTRAINT audio_session_admissions_visibility CHECK ( + state <> 'visible' OR visibility_observed_at IS NOT NULL + ); + +DROP INDEX idx_audio_session_admissions_cleanup_requested; +CREATE INDEX idx_audio_session_admissions_cleanup_requested + ON audio_session_admissions (community_id, cleanup_requested_at) + WHERE cleanup_requested_at IS NOT NULL + AND state IN ('reserved', 'active', 'visible'); + +-- Mixed-version relays must not retain the former active -> finished path. +-- An older binary consequently fails closed after this migration instead of +-- treating an authorization receipt as proof that visibility occurred. +CREATE FUNCTION audio_admission_visibility_transition_guard() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.state = OLD.state THEN + RETURN NEW; + END IF; + IF (OLD.state = 'reserved' AND NEW.state IN ('active', 'aborted')) + OR (OLD.state = 'active' AND NEW.state IN ('visible', 'aborted')) + OR (OLD.state = 'visible' AND NEW.state IN ('finished', 'aborted')) THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'invalid audio admission state transition'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER audio_admission_visibility_transition_guard + BEFORE UPDATE OF state ON audio_session_admissions + FOR EACH ROW EXECUTE FUNCTION audio_admission_visibility_transition_guard(); From 12551858661485bab6d3b62b7863d3ea2be224c9 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:55:16 -0500 Subject: [PATCH 03/11] fix(auth): restore O4AB compile seams Keep extracted protected transports fail closed until their owning route inventory and client-production slices land, and update the directly affected lint and proof fixtures. Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/context/binding.rs | 1 + crates/buzz-auth/src/context/mod.rs | 10 +- crates/buzz-auth/src/lease.rs | 2 + crates/buzz-auth/src/provider/tests.rs | 6 +- crates/buzz-db/src/channel.rs | 59 ++++ crates/buzz-db/src/lib.rs | 18 ++ crates/buzz-db/src/public_projection.rs | 271 ++++++++++++++++++ crates/buzz-relay/src/api/mod.rs | 1 + .../src/authorization_runtime/finalization.rs | 83 ++++++ .../src/authorization_runtime/mod.rs | 6 + crates/buzz-relay/src/connection.rs | 39 +++ .../src/handlers/moderation_authz.rs | 70 +++++ crates/buzz-relay/src/lib.rs | 5 + crates/buzz-relay/src/protected_surface.rs | 129 +++++++++ 14 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-db/src/public_projection.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/finalization.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/mod.rs create mode 100644 crates/buzz-relay/src/protected_surface.rs diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index 2bb56754ce..262a9f1c04 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -601,6 +601,7 @@ impl VersionedBindingRef { } } + #[allow(clippy::too_many_arguments)] pub(crate) fn from_evidence_adapter( authorization_domain: CommunityId, binding_id: Uuid, diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index ce24e54390..ba033074f1 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -228,10 +228,9 @@ impl AuthContextInput { #[allow(dead_code)] pub(crate) fn verified_owner_pubkey(&self) -> Option { - match self.nostr_proof.verified_delegation() { - Some(delegation) => Some(delegation.owner_pubkey()), - None => None, - } + self.nostr_proof + .verified_delegation() + .map(VerifiedTransportDelegation::owner_pubkey) } /// Server-resolved tenant carried by the finalization input. @@ -348,6 +347,7 @@ impl AuthContext { /// /// `now_unix_seconds` must come from the server clock for the authorization /// decision being finalized. + #[allow(dead_code)] pub(crate) fn finalize_v1( input: AuthContextInput, federated_policy: ResolvedFederatedPolicy, @@ -363,6 +363,7 @@ impl AuthContext { ) } + #[allow(dead_code)] pub(crate) fn finalize_v1_with_lease( input: AuthContextInput, federated_policy: ResolvedFederatedPolicy, @@ -623,6 +624,7 @@ pub(crate) fn validate_context_evidence( } impl FederatedAuthorization { + #[allow(dead_code)] pub(crate) const fn active_binding(&self) -> Option<&VersionedBindingRef> { match self { Self::NotRequired => None, diff --git a/crates/buzz-auth/src/lease.rs b/crates/buzz-auth/src/lease.rs index 39d91013a7..cff5c3ec22 100644 --- a/crates/buzz-auth/src/lease.rs +++ b/crates/buzz-auth/src/lease.rs @@ -315,6 +315,7 @@ pub struct AuthorizationLease { impl AuthorizationLease { #[allow(clippy::too_many_arguments)] + #[allow(dead_code)] pub(crate) fn issue( lease_version: LeaseVersion, authorization_domain: CommunityId, @@ -812,6 +813,7 @@ impl LeaseValidationError { } } +#[allow(dead_code)] pub(crate) fn conservative_expiry( now: AuthorizationTime, provider_effective_until: u64, diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 190cdf7da6..71bbfca200 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -336,10 +336,8 @@ fn capability_coverage_is_exhaustive(capability: AuthorizationCapability) { fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { match transport { AuthTransport::RelayWebSocket => AuthMethod::Nip42, - AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaDownload => { - AuthMethod::Nip98 - } - AuthTransport::MediaUpload => AuthMethod::Blossom, + AuthTransport::HttpBridge | AuthTransport::Git => AuthMethod::Nip98, + AuthTransport::MediaUpload | AuthTransport::MediaDownload => AuthMethod::Blossom, AuthTransport::Audio => AuthMethod::Nip42, } } diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 13fe052805..32e5073b6c 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -1587,6 +1587,65 @@ pub async fn get_member_role( Ok(row.map(|r| r.try_get("role")).transpose()?) } +/// Get the active role on the caller's transaction snapshot. +pub async fn get_member_role_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT cm.role::text AS role 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_optional(&mut **transaction) + .await?; + Ok(row.map(|r| r.try_get("role")).transpose()?) +} + +/// Revalidate uncached read access to an entire channel set in one database +/// statement. +pub async fn channel_set_read_authorized( + pool: &PgPool, + community_id: CommunityId, + channel_ids: &[Uuid], + actor: &[u8], +) -> Result { + if channel_ids.is_empty() { + return Ok(true); + } + let allowed = sqlx::query_scalar::<_, bool>( + r#" + SELECT COUNT(DISTINCT c.id) = cardinality($2::uuid[]) + FROM channels c + WHERE c.community_id = $1 + AND c.id = ANY($2::uuid[]) + AND c.deleted_at IS NULL + AND ( + c.visibility::text <> 'private' + OR EXISTS ( + SELECT 1 + FROM channel_members cm + WHERE cm.community_id = c.community_id + AND cm.channel_id = c.id + AND cm.pubkey = $3 + AND cm.removed_at IS NULL + ) + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_ids) + .bind(actor) + .fetch_one(pool) + .await?; + Ok(allowed) +} + /// Archive ephemeral channels whose TTL deadline has passed. /// /// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 590590a345..44e362a2ec 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -15,6 +15,8 @@ pub mod admin_moderation; pub mod api_token; /// Relay-scoped archived identity persistence (NIP-IA). pub mod archived_identities; +/// Transaction-owned admission records for protected audio sessions. +pub mod audio_admission; /// Channel and membership persistence. pub mod channel; /// Direct message channel persistence. @@ -39,6 +41,12 @@ pub mod moderation; pub mod partition; /// Buzz product-feedback sidecar persistence. pub mod product_feedback; +/// PostgreSQL-authoritative visibility for protected object-store content. +pub mod protected_publication; +/// Monotonic migration and cutover authority for protected object visibility. +pub mod protected_visibility; +/// Fail-closed compatibility seam replaced by the client-production slice. +pub mod public_projection; /// Community-scoped push lease and durable wake-outbox persistence. pub mod push; /// Reaction persistence. @@ -2447,6 +2455,16 @@ impl Db { channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await } + /// Revalidate uncached read access to a complete channel set in one query. + pub async fn channel_set_read_authorized( + &self, + community_id: CommunityId, + channel_ids: &[Uuid], + pubkey: &[u8], + ) -> Result { + channel::channel_set_read_authorized(&self.pool, community_id, channel_ids, pubkey).await + } + /// Archive ephemeral channels whose TTL deadline has passed. pub async fn reap_expired_ephemeral_channels( &self, diff --git a/crates/buzz-db/src/public_projection.rs b/crates/buzz-db/src/public_projection.rs new file mode 100644 index 0000000000..a881118252 --- /dev/null +++ b/crates/buzz-db/src/public_projection.rs @@ -0,0 +1,271 @@ +//! Fail-closed compatibility contract for optional public projection. +//! +//! The client-production slice replaces this interface with durable storage +//! beside its owning migration. Until then every database entrypoint denies. + +use std::fmt; + +use buzz_core::{CommunityId, StoredEvent}; +use nostr::Event; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +fn unavailable() -> Result { + Err(DbError::InvalidData( + "public projection storage is not installed".to_owned(), + )) +} + +/// Opaque source generation for one public projection. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProjectionBindingOrigin { + _sealed: (), +} + +impl ProjectionBindingOrigin { + /// Stable binding identifier used only for server-side fencing. + pub const fn binding_id(self) -> Uuid { + unreachable!() + } + + /// Positive binding generation used only for server-side fencing. + pub const fn binding_version(self) -> u64 { + unreachable!() + } +} + +impl fmt::Debug for ProjectionBindingOrigin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProjectionBindingOrigin([redacted])") + } +} + +/// Server-only disposition for a public projection head. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionDisposition { + /// A current binding owns a label-bearing assertion. + Active, + /// The assertion is the canonical inactive replacement. + Inactive, +} + +/// Current server-only ownership metadata for an assertion coordinate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProjectionHead { + _sealed: (), +} + +impl ProjectionHead { + /// Exact signed event installed with this ownership record. + pub const fn event_id(self) -> [u8; 32] { + unreachable!() + } + + /// Whether the current projection is active or inactive. + pub const fn disposition(self) -> ProjectionDisposition { + unreachable!() + } + + /// Exact binding generation that created the current projection. + pub const fn origin(self) -> Option { + unreachable!() + } +} + +/// Transaction-owned active publication permit. +#[derive(Debug)] +pub struct ActivePublicProjectionPermit { + _sealed: (), +} + +impl ActivePublicProjectionPermit { + /// Current stored projection at the locked coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + unreachable!() + } + + /// Exact active binding generation retained through commit. + pub const fn origin(&self) -> ProjectionBindingOrigin { + unreachable!() + } + + /// Commit is unavailable before the client-production migration. + pub async fn commit( + self, + _event: &Event, + _disposition: ProjectionDisposition, + ) -> Result { + unavailable() + } +} + +/// Begin active publication; denied before durable projection storage exists. +pub async fn begin_active_public_projection( + _db: &Db, + _community_id: CommunityId, + _relay_pubkey: &[u8], + _issuer: &str, + _subject: &str, + _subject_pubkey: &[u8], +) -> Result> { + unavailable() +} + +/// Materialization is unavailable before durable projection storage exists. +pub async fn materialize_public_projection_retirements( + _db: &Db, + _domains: &[CommunityId], + _relay_pubkey: &[u8], +) -> Result { + unavailable() +} + +/// Retryable retirement work claimed by one relay replica. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectionRetirementClaim { + _sealed: (), +} + +impl ProjectionRetirementClaim { + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + unreachable!() + } + + /// Public subject key whose assertion may need retirement. + pub fn old_pubkey(&self) -> &[u8] { + unreachable!() + } + + /// Exact retired source generation, when available. + pub const fn source_origin(&self) -> Option { + unreachable!() + } +} + +/// Claiming is unavailable before durable projection storage exists. +pub async fn claim_public_projection_retirement( + _db: &Db, + _domains: &[CommunityId], + _relay_pubkey: &[u8], +) -> Result> { + unavailable() +} + +/// Transaction-owned view of one claimed retirement. +#[derive(Debug)] +pub struct ProjectionRetirementPermit { + _sealed: (), +} + +impl ProjectionRetirementPermit { + /// Public subject key selected by the lifecycle operation. + pub fn old_pubkey(&self) -> &[u8] { + unreachable!() + } + + /// Current event at the locked assertion coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + unreachable!() + } + + /// Current private projection ownership metadata. + pub const fn head(&self) -> Option { + unreachable!() + } + + /// Current active binding for the key, if reused. + pub const fn active_origin(&self) -> Option { + unreachable!() + } + + /// Retired binding generation carried by durable work. + pub const fn source_origin(&self) -> Option { + unreachable!() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn finish_no_projection(self) -> Result<()> { + unavailable() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn finish_superseded(self, _current: &Event) -> Result<()> { + unavailable() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn finish_newer_projection(self) -> Result<()> { + unavailable() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn finish_existing_inactive(self) -> Result<()> { + unavailable() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn finish_inactive(self, _inactive: &Event) -> Result { + unavailable() + } + + /// Retry deferral is unavailable before durable projection storage exists. + pub async fn defer(self) -> Result<()> { + unavailable() + } +} + +/// Begin retirement; denied before durable projection storage exists. +pub async fn begin_public_projection_retirement( + _db: &Db, + _claim: ProjectionRetirementClaim, +) -> Result { + unavailable() +} + +/// Delivery work retained until local and replicated fan-out complete. +#[derive(Debug)] +pub struct ProjectionDeliveryPermit { + _sealed: (), +} + +impl ProjectionDeliveryPermit { + /// Exact current inactive event. + pub const fn stored(&self) -> &StoredEvent { + unreachable!() + } + + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + unreachable!() + } + + /// Completion is unavailable before durable projection storage exists. + pub async fn complete(self) -> Result<()> { + unavailable() + } + + /// Retry deferral is unavailable before durable projection storage exists. + pub async fn defer(self) -> Result<()> { + unavailable() + } +} + +/// Begin delivery; denied before durable projection storage exists. +pub async fn begin_public_projection_delivery( + _db: &Db, + _domains: &[CommunityId], + _relay_pubkey: &[u8], +) -> Result> { + unavailable() +} + +/// Count unfinished work; denied before durable projection storage exists. +pub async fn unfinished_public_projection_retirements( + _db: &Db, + _domains: &[CommunityId], + _relay_pubkey: &[u8], +) -> Result { + unavailable() +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b..d09ffa0c5f 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod events; pub mod git; pub mod invites; pub mod media; +pub mod media_migration; pub mod mesh_demo; pub mod nip05; pub mod operator; diff --git a/crates/buzz-relay/src/authorization_runtime/finalization.rs b/crates/buzz-relay/src/authorization_runtime/finalization.rs new file mode 100644 index 0000000000..06c7c14496 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/finalization.rs @@ -0,0 +1,83 @@ +//! Compile-stable finalization types for the protected-transport slice. +//! +//! The invalidation slice installs the runtime finalizer. This module exposes +//! only the sealed types needed to compile transport coupling before that +//! implementation is present. + +use std::fmt; + +use buzz_auth::{AuthorizationProfileId, FederatedPrincipal, PolicyVersion}; +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +/// Server-owned activation mode for one exact authorization domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationMode { + /// Do not evaluate federated identity or provider policy. + Off, + /// Evaluate read-only provider policy without authority changes. + Shadow, + /// Produce display-only output after full verification. + VerifyOnly, + /// Issue bounded protected access after finalization. + Enforce, +} + +/// Direct provider decision sealed for atomic first enrollment. +#[must_use] +pub struct EnrollmentDisposition { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + correlation_id: Uuid, + expires_at: u64, +} + +impl EnrollmentDisposition { + /// Exact server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Direct actor whose key was attested. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Literal issuer-qualified principal staged for enrollment. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Installed provider profile that made the decision. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Provider policy revision that made the decision. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Exact decision correlation identifier. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Earliest authoritative expiry bound for enrollment. + pub const fn expires_at(&self) -> u64 { + self.expires_at + } +} + +impl fmt::Debug for EnrollmentDisposition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EnrollmentDisposition") + .field("evidence", &"[redacted]") + .finish() + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs new file mode 100644 index 0000000000..7e2175b950 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -0,0 +1,6 @@ +//! Provider-neutral authorization interfaces available before runtime installation. + +/// Activation and enrollment types consumed by protected transports. +pub mod finalization; +/// Protected transport authorization and lease fencing. +pub mod transport; diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 55de214e78..a04d6cb98c 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -23,6 +23,45 @@ use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::{run_registered_community_connection, AppState}; use buzz_pubsub::EventTopic; +/// Fail-closed release check evaluated at the response boundary. +pub(crate) trait OutboundReleaseFence: Send + Sync { + /// Return true only while the retained authority is still current. + fn release(&self) -> bool; +} + +impl OutboundReleaseFence for crate::authorization_runtime::transport::ProtectedAuthorization { + fn release(&self) -> bool { + self.release_fetched(()).is_ok() + } +} + +/// Revalidate aggregate channel and protected authority immediately before an +/// HTTP response is released. +pub(crate) async fn release_channel_set_read_authority( + db: buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + channel_ids: Vec, + actor: Vec, + protected: Option>, +) -> bool { + if protected + .as_ref() + .is_some_and(|authority| authority.revalidate().is_err()) + { + return false; + } + if !db + .channel_set_read_authorized(community_id, &channel_ids, &actor) + .await + .unwrap_or(false) + { + return false; + } + protected + .as_ref() + .is_none_or(|authority| authority.revalidate().is_ok()) +} + /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 3d4b7f4a0a..4a38441220 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use sqlx::{Postgres, Transaction}; use uuid::Uuid; use crate::state::AppState; @@ -137,6 +138,75 @@ pub async fn authorize_moderation_action( ) } +/// Revalidate role authority under locks owned by the caller's PostgreSQL +/// authorization transaction. +pub async fn authorize_moderation_action_tx( + transaction: &mut Transaction<'_, Postgres>, + tenant: &TenantContext, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, +) -> anyhow::Result { + sqlx::query("LOCK TABLE relay_members IN SHARE MODE") + .execute(&mut **transaction) + .await?; + if matches!( + action, + ModerationAction::DeleteMessage | ModerationAction::Kick + ) { + sqlx::query("LOCK TABLE channel_members IN SHARE MODE") + .execute(&mut **transaction) + .await?; + } + + let community = tenant.community(); + let actor_role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(hex::encode(actor_pubkey)) + .fetch_optional(&mut **transaction) + .await?; + let target_role: Option = match (actor_role.as_deref(), action, target) { + ( + Some("admin"), + ModerationAction::Ban | ModerationAction::Timeout, + ModerationTarget::Pubkey(target), + ) => { + sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(hex::encode(target)) + .fetch_optional(&mut **transaction) + .await? + } + _ => None, + }; + let channel_role: Option = match (actor_role.as_deref(), action, channel_id) { + (Some("owner") | Some("admin"), _, _) => None, + (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { + sqlx::query_scalar( + "SELECT role::text FROM channel_members WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(actor_pubkey) + .fetch_optional(&mut **transaction) + .await? + } + _ => None, + }; + decide_authority( + actor_role.as_deref(), + target_role.as_deref(), + channel_role.as_deref(), + action, + ) +} + /// Pure authorization decision from resolved roles — the policy, factored out /// of the I/O so it is exhaustively unit-testable. /// diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 904af74803..cf49bd2786 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -4,6 +4,9 @@ mod admission; +/// Provider-neutral protected-transport authorization seams. +pub mod authorization_runtime; + /// REST API route handlers. pub mod api; /// WebSocket audio relay for huddle voice channels. @@ -31,6 +34,8 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// Fail-closed protected-surface compatibility seam. +pub mod protected_surface; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/protected_surface.rs b/crates/buzz-relay/src/protected_surface.rs new file mode 100644 index 0000000000..137186a8b5 --- /dev/null +++ b/crates/buzz-relay/src/protected_surface.rs @@ -0,0 +1,129 @@ +//! Fail-closed compatibility seam for protected transport coupling. +//! +//! The route-inventory slice replaces this bounded seam with the complete +//! machine-readable inventory. Unknown surfaces never match, and effects that +//! need the later inventory deny while enforcement is active. + +use buzz_auth::{AuthTransport, AuthorizationCapability}; + +use crate::authorization_runtime::finalization::AuthorizationMode; + +/// Closed effect identifiers referenced by the protected-transport slice. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectSurfaceId { + /// Autonomous or delayed workflow execution. + WorkflowBackgroundExecution, + /// Legacy best-effort audit delivery. + LegacyAuditDelivery, +} + +/// Proof that one effect is permitted in a non-enforcing compatibility mode. +pub struct EffectPermit { + id: EffectSurfaceId, +} + +impl EffectPermit { + /// Registered effect represented by this permit. + pub const fn id(&self) -> EffectSurfaceId { + self.id + } +} + +/// Fail-closed compatibility error before the complete inventory is installed. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum EffectPermitError { + /// The effect requires the complete route inventory in enforcing mode. + #[error("protected effect inventory is not installed")] + InventoryRequired, +} + +/// Deny registered effects in enforcing mode until the complete inventory is installed. +pub fn require_effect_permit( + mode: Option, + id: EffectSurfaceId, +) -> Result { + if mode == Some(AuthorizationMode::Enforce) { + return Err(EffectPermitError::InventoryRequired); + } + Ok(EffectPermit { id }) +} + +/// Recheck the stable handler surface, proof transport, and portable capability. +pub fn protected_operation_matches( + surface: &str, + transport: AuthTransport, + capability: AuthorizationCapability, +) -> bool { + use AuthorizationCapability as Capability; + match surface { + "ws_req" | "ws_count" | "ws_fanout" | "client.status.current" => { + transport == AuthTransport::RelayWebSocket && capability == Capability::CommunityRead + } + "ws_event" => { + transport == AuthTransport::RelayWebSocket + && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "event_ingest" => { + matches!( + transport, + AuthTransport::RelayWebSocket | AuthTransport::HttpBridge + ) && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "http_events" => { + transport == AuthTransport::HttpBridge + && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "http_query" | "http_count" => { + transport == AuthTransport::HttpBridge && capability == Capability::CommunityRead + } + "http_moderation_read" => { + transport == AuthTransport::HttpBridge && capability == Capability::Moderate + } + "media.upload" => { + transport == AuthTransport::MediaUpload && capability == Capability::MediaWrite + } + "media.read" => { + transport == AuthTransport::MediaDownload && capability == Capability::MediaRead + } + "git.info_refs" => { + transport == AuthTransport::Git + && matches!(capability, Capability::GitRead | Capability::GitWrite) + } + "git.upload_pack" => transport == AuthTransport::Git && capability == Capability::GitRead, + "git.receive_pack" => transport == AuthTransport::Git && capability == Capability::GitWrite, + "audio.join" => transport == AuthTransport::Audio && capability == Capability::AudioJoin, + "invite.mint" => { + transport == AuthTransport::HttpBridge && capability == Capability::InviteMint + } + "invite.claim" => { + transport == AuthTransport::HttpBridge && capability == Capability::InviteClaim + } + _ => false, + } +} + +/// Return the exact HTTP bridge event-ingest capability. +pub const fn event_ingest_capability(kind: u32) -> AuthorizationCapability { + match kind { + 9040..=9044 => AuthorizationCapability::Moderate, + _ => AuthorizationCapability::CommunityWrite, + } +} + +/// Resolve Git's `info/refs` capability from the validated service name. +pub fn git_info_refs_capability(service: &str) -> Option { + match service { + "git-upload-pack" => Some(AuthorizationCapability::GitRead), + "git-receive-pack" => Some(AuthorizationCapability::GitWrite), + _ => None, + } +} From 9a433ac52dbcfc4600b36ee38dcf3f4b11b36ef7 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:03:55 -0500 Subject: [PATCH 04/11] fix(db): expose protected audio compile adapters Share the existing membership lock within buzz-db and keep the operation-receipt inspection helper test-only until its owning invalidation slice lands. Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/audio_admission.rs | 26 ++++++++++++++++++++++++++ crates/buzz-db/src/channel.rs | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/buzz-db/src/audio_admission.rs b/crates/buzz-db/src/audio_admission.rs index 5ba50fca8c..6ec18fafeb 100644 --- a/crates/buzz-db/src/audio_admission.rs +++ b/crates/buzz-db/src/audio_admission.rs @@ -731,6 +731,32 @@ mod tests { use super::*; use crate::channel::{ChannelType, ChannelVisibility}; + impl crate::Db { + async fn authorization_operation_receipt_fingerprint( + &self, + community_id: CommunityId, + operation_id: Uuid, + ) -> crate::Result> { + let value: Option> = sqlx::query_scalar( + "SELECT request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&self.pool) + .await?; + value + .map(|bytes| { + bytes.try_into().map_err(|_| { + crate::DbError::InvalidData( + "authorization receipt fingerprint must be 32 bytes".to_owned(), + ) + }) + }) + .transpose() + } + } + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; async fn setup() -> (crate::Db, CommunityId, Uuid, [u8; 32]) { diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 32e5073b6c..8cf7ecd69d 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -351,7 +351,7 @@ const CHANNEL_MEMBERSHIP_LOCK_NAMESPACE: &str = "buzz_channel_membership:"; /// Take the per-channel membership lock. MUST be the first statement in the /// transaction that then reads roles/owner counts and writes membership, so the /// whole check-then-write sequence is atomic against a concurrent one. -async fn acquire_channel_membership_lock( +pub(crate) async fn acquire_channel_membership_lock( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, channel_id: Uuid, From cbd53645bfd81119325823344c9b110699641c86 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:17:30 -0500 Subject: [PATCH 05/11] fix(auth): make O4AB independently fail closed Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/lib.rs | 11 + crates/buzz-db/src/audio_admission.rs | 26 --- crates/buzz-db/src/channel.rs | 32 +++ crates/buzz-db/src/lib.rs | 19 ++ crates/buzz-db/src/migration.rs | 2 +- crates/buzz-relay/src/api/admin/mod.rs | 13 +- crates/buzz-relay/src/api/invites.rs | 10 +- .../src/authorization_runtime/ephemeral.rs | 159 ++++++++++++++ .../src/authorization_runtime/executor.rs | 199 ++++++++++++++++++ .../src/authorization_runtime/mod.rs | 6 + .../src/authorization_runtime/restore.rs | 51 +++++ crates/buzz-relay/src/corporate_identity.rs | 9 + crates/buzz-relay/src/handlers/auth.rs | 5 +- crates/buzz-relay/src/handlers/event.rs | 6 +- crates/buzz-relay/src/handlers/ingest.rs | 52 +++++ crates/buzz-relay/src/mesh_boot.rs | 27 ++- crates/buzz-relay/src/router.rs | 9 +- crates/buzz-relay/src/state.rs | 67 ++++++ 18 files changed, 655 insertions(+), 48 deletions(-) create mode 100644 crates/buzz-relay/src/authorization_runtime/ephemeral.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/executor.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/restore.rs diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 38433c880b..31577f8f62 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -59,6 +59,17 @@ pub use evidence_adapter::{ ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput, VerifiedEvidenceAdapter, }; + +/// Opaque display-only result used by the protected-transport interface. +/// +/// The finalization slice replaces this compatibility type with the complete +/// current-binding disposition. It intentionally has no public constructor so +/// this earlier review unit cannot mint verification status or authority. +#[must_use] +#[derive(PartialEq, Eq)] +pub struct VerificationOnlyDisposition { + _private: (), +} pub use lease::{ AccessLeasePolicy, ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockError, AuthorizationClockSkew, AuthorizationLease, AuthorizationLeaseValidator, diff --git a/crates/buzz-db/src/audio_admission.rs b/crates/buzz-db/src/audio_admission.rs index 6ec18fafeb..5ba50fca8c 100644 --- a/crates/buzz-db/src/audio_admission.rs +++ b/crates/buzz-db/src/audio_admission.rs @@ -731,32 +731,6 @@ mod tests { use super::*; use crate::channel::{ChannelType, ChannelVisibility}; - impl crate::Db { - async fn authorization_operation_receipt_fingerprint( - &self, - community_id: CommunityId, - operation_id: Uuid, - ) -> crate::Result> { - let value: Option> = sqlx::query_scalar( - "SELECT request_fingerprint FROM authorization_operation_receipts \ - WHERE community_id=$1 AND operation_id=$2", - ) - .bind(community_id.as_uuid()) - .bind(operation_id) - .fetch_optional(&self.pool) - .await?; - value - .map(|bytes| { - bytes.try_into().map_err(|_| { - crate::DbError::InvalidData( - "authorization receipt fingerprint must be 32 bytes".to_owned(), - ) - }) - }) - .transpose() - } - } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; async fn setup() -> (crate::Db, CommunityId, Uuid, [u8; 32]) { diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 8cf7ecd69d..2d116588ae 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -1607,6 +1607,38 @@ pub async fn get_member_role_tx( Ok(row.map(|r| r.try_get("role")).transpose()?) } +/// Revalidate uncached access to one stored channel at a release boundary. +pub async fn channel_read_authorized( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + actor: &[u8], +) -> Result { + let allowed = sqlx::query_scalar::<_, bool>( + r#" + SELECT c.visibility::text <> 'private' + OR EXISTS ( + SELECT 1 + FROM channel_members cm + WHERE cm.community_id = c.community_id + AND cm.channel_id = c.id + AND cm.pubkey = $3 + AND cm.removed_at IS NULL + ) + FROM channels c + WHERE c.community_id = $1 + AND c.id = $2 + AND c.deleted_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_optional(pool) + .await?; + Ok(allowed.unwrap_or(false)) +} + /// Revalidate uncached read access to an entire channel set in one database /// statement. pub async fn channel_set_read_authorized( diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 44e362a2ec..4a0e9ea667 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2455,6 +2455,16 @@ impl Db { channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await } + /// Revalidate uncached read access to one channel in one query. + pub async fn channel_read_authorized( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + channel::channel_read_authorized(&self.pool, community_id, channel_id, pubkey).await + } + /// Revalidate uncached read access to a complete channel set in one query. pub async fn channel_set_read_authorized( &self, @@ -2465,6 +2475,15 @@ impl Db { channel::channel_set_read_authorized(&self.pool, community_id, channel_ids, pubkey).await } + /// Fail closed before the invalidation slice owns durable operation receipts. + pub async fn authorization_operation_receipt_fingerprint( + &self, + _community_id: CommunityId, + _operation_id: Uuid, + ) -> Result> { + Ok(None) + } + /// Archive ephemeral channels whose TTL deadline has passed. pub async fn reap_expired_ephemeral_channels( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 860a300e2f..21eb67873c 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -566,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 30); + assert_eq!(migrations.len(), 40); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0..44c1c7939c 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -223,12 +223,13 @@ async fn feedback_attachment( return Err(ApiError::not_found()); } - let response = crate::api::media::serve_blob_for_tenant(&state, &tenant, &sha256, &headers) - .await - .map_err(|error| match error { - buzz_media::MediaError::NotFound => ApiError::not_found(), - _ => ApiError::internal(), - })?; + let response = + crate::api::media::serve_blob_for_tenant(&state, &tenant, &sha256, &headers, None) + .await + .map_err(|error| match error { + buzz_media::MediaError::NotFound => ApiError::not_found(), + _ => ApiError::internal(), + })?; tracing::info!( feedback_id = %feedback.id, community_id = %feedback.community_id, diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index c8cd2b1121..a8ad966dc3 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -264,10 +264,12 @@ async fn authenticate( )?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( + state, + tenant.community(), headers, - &state.config.corporate_identity, - ); + ) + .map_err(crate::corporate_identity::CorporateIdentityError::into_api_error)?; let auth_tag = headers .get("x-auth-tag") .and_then(|value| value.to_str().ok()); @@ -275,7 +277,7 @@ async fn authenticate( state, tenant.community(), pubkey, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await diff --git a/crates/buzz-relay/src/authorization_runtime/ephemeral.rs b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs new file mode 100644 index 0000000000..a4f7816d2f --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs @@ -0,0 +1,159 @@ +//! Fail-closed ephemeral-authority interfaces for the lower review unit. +//! +//! The session-conformance slice installs signed, durable authority. Production +//! verification here always denies; test-only constructors preserve the +//! existing realtime fixture surface without creating production authority. + +use std::{fmt, sync::Arc}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use buzz_core::CommunityId; +use thiserror::Error; + +use super::transport::ProtectedAuthorization; +use crate::state::AppState; + +const PRESENCE_PREFIX: &str = "pa1:"; + +/// Protected presence envelope decoded before authority verification. +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct ProtectedPresenceValue { + pub(crate) status: String, + pub(crate) context_id: [u8; 32], + pub(crate) authority: String, +} + +impl fmt::Debug for ProtectedPresenceValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedPresenceValue") + .field("status", &"[redacted]") + .field("context_id", &"[redacted]") + .field("authority", &"[redacted]") + .finish() + } +} + +/// Decode an envelope while retaining fail-closed authority verification. +pub(crate) fn decode_presence( + value: &str, +) -> Result, EphemeralAuthorityError> { + let Some(value) = value.strip_prefix(PRESENCE_PREFIX) else { + return Ok(None); + }; + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + Ok(Some(serde_json::from_slice(&bytes)?)) +} + +/// Refuse to seal ephemeral authority before the session slice is installed. +pub(crate) fn seal_context( + _state: &AppState, + _authority: &ProtectedAuthorization, + _context_id: [u8; 32], +) -> Result { + Err(EphemeralAuthorityError::AuthorityRequired) +} + +/// Authority verifier that denies production input until its owning slice. +#[derive(Clone)] +pub(crate) enum AuthorityTokenVerifier { + ProductionDeny, + #[cfg(test)] + Test(Arc), +} + +/// Test-compatible retained authority with bounded synchronous expiry checks. +#[derive(Clone)] +pub(crate) struct RetainedEphemeralAuthority { + gate: Arc, + expires_at: u64, +} + +impl RetainedEphemeralAuthority { + pub(crate) fn expires_at(&self) -> u64 { + self.expires_at + } + + pub(crate) async fn release(&self) -> bool { + self.is_time_valid() && self.gate.load(std::sync::atomic::Ordering::SeqCst) + } + + pub(crate) fn is_time_valid(&self) -> bool { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .is_ok_and(|duration| duration.as_secs() < self.expires_at) + } +} + +impl AuthorityTokenVerifier { + /// Build a production verifier that remains unavailable in this slice. + pub(crate) fn new(_db: buzz_db::Db, _relay_secret: &[u8]) -> Self { + Self::ProductionDeny + } + + #[cfg(test)] + pub(crate) fn allow_for_test() -> Self { + Self::Test(Arc::new(std::sync::atomic::AtomicBool::new(true))) + } + + #[cfg(test)] + pub(crate) fn deny_for_test() -> Self { + Self::Test(Arc::new(std::sync::atomic::AtomicBool::new(false))) + } + + #[cfg(test)] + pub(crate) fn conditional_for_test(gate: Arc) -> Self { + Self::Test(gate) + } + + pub(crate) async fn verify_context( + &self, + _community_id: CommunityId, + _context_id: [u8; 32], + _token: &str, + ) -> Result { + self.retained_test_authority() + } + + pub(crate) async fn verify_actor_context( + &self, + _community_id: CommunityId, + _context_id: [u8; 32], + _actor_pubkey: [u8; 32], + _token: &str, + ) -> Result { + self.retained_test_authority() + } + + fn retained_test_authority( + &self, + ) -> Result { + match self { + Self::ProductionDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + #[cfg(test)] + Self::Test(gate) if gate.load(std::sync::atomic::Ordering::SeqCst) => { + Ok(RetainedEphemeralAuthority { + gate: Arc::clone(gate), + expires_at: u64::MAX, + }) + } + #[cfg(test)] + Self::Test(_) => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + } + } +} + +/// Fail-closed ephemeral-authority error. +#[derive(Debug, Error)] +pub(crate) enum EphemeralAuthorityError { + #[error("ephemeral sender authority is required")] + AuthorityRequired, + #[error("ephemeral sender authority envelope is invalid")] + InvalidEnvelope, + #[error("ephemeral sender authority is expired or invalidated")] + ExpiredOrInvalidated, + #[error("ephemeral sender authority serialization failed")] + Serialization(#[from] serde_json::Error), +} diff --git a/crates/buzz-relay/src/authorization_runtime/executor.rs b/crates/buzz-relay/src/authorization_runtime/executor.rs new file mode 100644 index 0000000000..ad1f6e98e6 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/executor.rs @@ -0,0 +1,199 @@ +//! Fail-closed transaction interfaces for the pre-finalization stack. +//! +//! Protected transports need stable types before the owning finalization and +//! invalidation slices land. This compatibility module never creates a permit +//! or starts a transaction; the later owning slice replaces it with the +//! transaction-owned implementation. + +use std::fmt; + +use buzz_core::CommunityId; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Transaction}; +use thiserror::Error; +use uuid::Uuid; + +use super::transport::{ + LeaseCurrentStateError, ProtectedEnrollmentAuthority, ProtectedOperationAuthority, + ProtectedTransportError, +}; + +/// Opaque commit fence that cannot be constructed in this review unit. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationCommitFence { + _private: (), +} + +impl fmt::Debug for AuthorizationCommitFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationCommitFence([unavailable])") + } +} + +/// Opaque ephemeral claim that cannot be constructed in this review unit. +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct EphemeralAuthorityClaim { + _private: (), +} + +impl EphemeralAuthorityClaim { + pub(super) fn from_authority( + _authority: &ProtectedOperationAuthority, + _event_id: [u8; 32], + ) -> Result { + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + } +} + +/// Stable retry identity for one protected operation. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProtectedOperationId(Uuid); + +impl ProtectedOperationId { + /// Derive a deterministic UUID from a domain-separated stable key. + pub fn derive( + authorization_domain: CommunityId, + operation_kind: &'static str, + stable_key: &[u8], + ) -> Result { + if operation_kind.is_empty() || operation_kind.len() > 128 || stable_key.is_empty() { + return Err(AuthorizationExecutionError::InvalidOperationIdentity); + } + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-operation-id-v1"); + digest.update(authorization_domain.as_uuid().as_bytes()); + digest.update((operation_kind.len() as u64).to_be_bytes()); + digest.update(operation_kind.as_bytes()); + digest.update((stable_key.len() as u64).to_be_bytes()); + digest.update(stable_key); + let digest: [u8; 32] = digest.finalize().into(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(Self(Uuid::from_bytes(bytes))) + } + + pub(crate) const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl fmt::Debug for ProtectedOperationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedOperationId([redacted])") + } +} + +/// Permit placeholder that cannot be minted before finalization lands. +pub struct SealedOperationPermit { + _private: (), +} + +impl SealedOperationPermit { + pub(super) fn from_authority( + _authority: &ProtectedOperationAuthority, + _operation_id: ProtectedOperationId, + _operation_kind: &'static str, + _request_fingerprint: [u8; 32], + ) -> Result { + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + } +} + +/// Enrollment permit placeholder that cannot be minted before finalization. +pub struct SealedEnrollmentPermit { + _private: (), +} + +impl SealedEnrollmentPermit { + pub(super) fn from_authority( + _authority: &ProtectedEnrollmentAuthority, + _operation_id: ProtectedOperationId, + _operation_kind: &'static str, + _request_fingerprint: [u8; 32], + ) -> Result { + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + } +} + +/// Start result retained for protected operation call-site compatibility. +pub enum AuthorizedOperationStart { + /// Previously committed bounded response. + Replay(Vec), + /// Transaction owned by the authorization executor. + Execute(Box), +} + +/// Transaction handle that can only be produced by the owning later slice. +pub struct AuthorizedOperation { + transaction: Transaction<'static, Postgres>, +} + +impl AuthorizedOperation { + /// Borrow the executor-owned transaction. + pub fn transaction(&mut self) -> &mut Transaction<'static, Postgres> { + &mut self.transaction + } + + /// Refuse to commit while the durable executor is unavailable. + pub async fn commit( + self, + _result_payload: &[u8], + ) -> Result, AuthorizationExecutionError> { + self.transaction.rollback().await?; + Err(AuthorizationExecutionError::RestoreUnavailable) + } +} + +/// Fail closed until the finalization slice installs transaction execution. +pub async fn begin_authorized_operation( + _state: &crate::state::AppState, + _permit: SealedOperationPermit, +) -> Result { + Err(AuthorizationExecutionError::RestoreUnavailable) +} + +/// Fail-closed protected mutation execution error. +#[derive(Debug, Error)] +pub enum AuthorizationExecutionError { + /// Database transaction failed. + #[error("protected operation database transaction failed")] + Database(#[from] sqlx::Error), + /// Database wrapper failed. + #[error("protected operation database transaction failed")] + Db(#[from] buzz_db::DbError), + /// Independent restore witness failed. + #[error("protected operation restore witness failed")] + Restore(#[from] super::restore::RestoreProtectionError), + /// Mandatory restore witness is unavailable. + #[error("protected operation restore witness is unavailable")] + RestoreUnavailable, + /// Captured authorization fence is incomplete. + #[error("protected operation commit fence is invalid")] + InvalidCommitFence, + /// Stable operation identity is malformed. + #[error("protected operation identity is invalid")] + InvalidOperationIdentity, + /// Stable identity was reused for different input. + #[error("protected operation retry conflicts with the committed request")] + ConflictingRetry, + /// Durable invalidation state denies the operation. + #[error("protected operation authority was invalidated")] + Invalidated, + /// The active binding changed or disappeared. + #[error("protected operation binding is no longer active")] + InvalidBinding, + /// Authorization expired before commit. + #[error("protected operation authorization expired before commit")] + Expired, + /// Replay result exceeded its bounded payload. + #[error("protected operation result is too large")] + ResultTooLarge, +} + +impl From for LeaseCurrentStateError { + fn from(_error: AuthorizationExecutionError) -> Self { + Self::Unavailable + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs index 7e2175b950..5bda6bbebb 100644 --- a/crates/buzz-relay/src/authorization_runtime/mod.rs +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -1,6 +1,12 @@ //! Provider-neutral authorization interfaces available before runtime installation. +/// Fail-closed ephemeral-authority interfaces installed by the session slice. +pub mod ephemeral; +/// Fail-closed transaction interfaces installed by the finalization slice. +pub mod executor; /// Activation and enrollment types consumed by protected transports. pub mod finalization; +/// Fail-closed restore-witness interfaces installed by the invalidation slice. +pub mod restore; /// Protected transport authorization and lease fencing. pub mod transport; diff --git a/crates/buzz-relay/src/authorization_runtime/restore.rs b/crates/buzz-relay/src/authorization_runtime/restore.rs new file mode 100644 index 0000000000..48fea8cc0b --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/restore.rs @@ -0,0 +1,51 @@ +//! Fail-closed restore-witness interfaces for the lower review unit. +//! +//! The invalidation slice replaces this module with the independent durable +//! witness. Until then no caller can begin, commit, or abort a protected +//! mutation through this interface. + +use buzz_core::CommunityId; +use thiserror::Error; +use uuid::Uuid; + +/// Disabled restore witness placeholder. +pub struct RestoreProtectionRuntime { + _private: (), +} + +impl RestoreProtectionRuntime { + /// Refuse to begin a protected mutation before the witness is installed. + pub async fn begin( + &self, + _domain: CommunityId, + _operation_id: Uuid, + _request_fingerprint: [u8; 32], + ) -> Result { + Err(RestoreProtectionError::DomainNotConfigured) + } +} + +/// Unconstructible mutation-witness guard retained for call-site typing. +pub struct RestoreMutationGuard { + _private: (), +} + +impl RestoreMutationGuard { + /// Refuse a commit while the independent witness is absent. + pub async fn commit(self) -> Result<(), RestoreProtectionError> { + Err(RestoreProtectionError::DomainNotConfigured) + } + + /// Refuse an abort while the independent witness is absent. + pub async fn abort(self) -> Result<(), RestoreProtectionError> { + Err(RestoreProtectionError::DomainNotConfigured) + } +} + +/// Fail-closed restore-witness error. +#[derive(Debug, Error)] +pub enum RestoreProtectionError { + /// The exact domain has no installed witness. + #[error("restore protection domain is not configured")] + DomainNotConfigured, +} diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 0c0cfabf3f..01a0486227 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -871,6 +871,15 @@ impl IdentityAssertionInput { pub fn jwt(&self) -> &str { &self.jwt } + + /// Preserve the inherited JWT-only WebSocket lane before the production + /// route slice installs deployment-verified provenance. + pub(crate) fn legacy_jwt(jwt: String) -> Self { + Self { + jwt, + assertion_transport: None, + } + } } impl fmt::Debug for IdentityAssertionInput { diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 1aa79aafea..af9b85b762 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -183,11 +183,14 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } + let identity_assertion = conn.corporate_identity_jwt.as_ref().map(|jwt| { + crate::corporate_identity::IdentityAssertionInput::legacy_jwt(jwt.clone()) + }); let identity_proof = match crate::corporate_identity::verify_corporate_identity( &state, conn.tenant.community(), pubkey, - conn.corporate_identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag_json.as_deref(), ) .await diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 288129fd62..e014b8fc37 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -631,13 +631,14 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc ( conn.conn_id, ctx.pubkey.to_bytes().to_vec(), ctx.pubkey, + ctx.agent_owner_pubkey, ctx.scopes.clone(), ctx.channel_ids.clone(), ), @@ -720,6 +721,9 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, + /// Sealed NIP-42 proof, when retained by a later route installer. + verified_proof: Option>, + /// Current direct federated evidence, when retained by a later route. + verified_assertion: Option>, /// Permission scopes granted to this connection. scopes: Vec, /// Token-level channel restriction, if the WebSocket auth used an API token. @@ -76,6 +82,12 @@ pub enum IngestAuth { Http { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, + /// Verified delegated owner, when present. + owner_pubkey: Option, + /// Sealed NIP-98 proof retained from this request. + verified_proof: Option>, + /// Current direct federated evidence retained from this request. + verified_assertion: Option>, /// Permission scopes granted to this request. scopes: Vec, /// How the HTTP request was authenticated. @@ -91,6 +103,34 @@ impl IngestAuth { } } + /// Verified delegated owner, when present. + pub fn owner_pubkey(&self) -> Option { + match self { + Self::Nip42 { owner_pubkey, .. } | Self::Http { owner_pubkey, .. } => *owner_pubkey, + } + } + + /// Sealed transport proof retained for protected authorization. + pub fn verified_proof(&self) -> Option<&Arc> { + match self { + Self::Nip42 { verified_proof, .. } | Self::Http { verified_proof, .. } => { + verified_proof.as_ref() + } + } + } + + /// Current direct federated evidence retained for protected authorization. + pub fn verified_assertion(&self) -> Option<&Arc> { + match self { + Self::Nip42 { + verified_assertion, .. + } + | Self::Http { + verified_assertion, .. + } => verified_assertion.as_ref(), + } + } + /// Pubkey used for principal-scoped accounting and policy lookups. pub fn principal_pubkey_bytes(&self) -> Vec { self.pubkey().to_bytes().to_vec() @@ -2996,6 +3036,9 @@ mod tests { .expect("sign feedback"); let auth = IngestAuth::Http { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![Scope::MessagesWrite], auth_method: HttpAuthMethod::Nip98, }; @@ -3398,6 +3441,9 @@ mod tests { let envelope_signer = nostr::Keys::generate(); let auth = IngestAuth::Nip42 { pubkey: principal.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], channel_ids: None, conn_id: Uuid::new_v4(), @@ -3416,6 +3462,9 @@ mod tests { let keys = nostr::Keys::generate(); let http_auth = IngestAuth::Http { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], auth_method: HttpAuthMethod::Nip98, }; @@ -3431,6 +3480,9 @@ mod tests { let keys = nostr::Keys::generate(); let ws_auth = IngestAuth::Nip42 { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], channel_ids: None, conn_id: uuid::Uuid::new_v4(), diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 2ad3ce5fa7..aa8ab84a33 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -157,6 +157,8 @@ pub struct MeshHandle { /// /// [`MeshAudioRouter`]: crate::audio::mesh::MeshAudioRouter pub audio_fence: Arc, + /// Reliable-control attachments accepted by the realtime media lane. + pub audio_attachments: Arc, /// The running mesh (status snapshots, shutdown). runtime: MeshRuntime, /// Per-room huddle owner-lease coordination. Shared with the WS-join owner @@ -189,6 +191,7 @@ impl MeshHandle { Arc::clone(&self.transport), self.local_runtime_id, Arc::clone(&self.audio_fence), + Arc::clone(&self.audio_attachments), rooms, Arc::clone(&self.owners), demo_echo, @@ -227,6 +230,7 @@ pub fn wire_mesh_consumers( transport: Arc, local_runtime_id: RuntimeId, audio_fence: Arc, + audio_attachments: Arc, rooms: Arc, owners: Arc, demo_echo: bool, @@ -239,9 +243,10 @@ pub fn wire_mesh_consumers( Arc::clone(&rooms), local_runtime_id, audio_fence, + Arc::clone(&audio_attachments), ); - dispatcher.register_datagrams(Box::new(move |_from, dgram| { - audio_router.on_media_datagram(&dgram); + dispatcher.register_datagrams(Box::new(move |from, dgram| { + audio_router.on_media_datagram(from, &dgram); })); // HuddleControl streams: owner-side peer registration for cross-pod @@ -253,6 +258,7 @@ pub fn wire_mesh_consumers( Arc::new(directory.clone()), local_runtime_id, Arc::clone(&owners), + audio_attachments, )); dispatcher.register_huddle_control(Box::new(move |from, hello, stream| { let acceptor = Arc::clone(&acceptor); @@ -486,6 +492,7 @@ pub async fn boot_mesh( let runtime = MeshRuntime::start(endpoint, membership, Some(registry)); let owners = Arc::new(crate::audio::join::HuddleOwnerRegistry::new()); + let audio_attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); // Dial seed peers now rather than waiting for the first reconcile tick. runtime.reconcile_now().await; @@ -528,6 +535,7 @@ pub async fn boot_mesh( local_runtime_id: runtime_id, dispatcher, audio_fence: Arc::new(crate::audio::mesh::GenerationFloor::new()), + audio_attachments, runtime, owners, })) @@ -719,6 +727,7 @@ mod tests { let dispatcher = MeshInboundDispatcher::default(); let fence = Arc::new(crate::audio::mesh::GenerationFloor::new()); + let attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") // never dialed .create_pool(Some(deadpool_redis::Runtime::Tokio1)) .unwrap(); @@ -728,6 +737,7 @@ mod tests { Arc::new(NoopTransport), rid(9), Arc::clone(&fence), + Arc::clone(&attachments), Arc::new(crate::audio::AudioRoomManager::new()), Arc::new(crate::audio::join::HuddleOwnerRegistry::new()), false, @@ -735,18 +745,21 @@ mod tests { ); let session = uuid::Uuid::new_v4(); + let fenced = FencedHeader { + session_id: session, + generation: 7, + owner_runtime_id: rid(1), + }; + let _attachment = attachments.register_owner_fanout(fenced, uuid::Uuid::new_v4(), u64::MAX); dispatcher.on_datagram( rid(1), MeshDatagram { - fenced: FencedHeader { - session_id: session, - generation: 7, - owner_runtime_id: rid(9), - }, + fenced, seq: 0, payload: vec![0, 1, 2], }, ); + tokio::task::yield_now().await; // The shared fence observed the datagram's generation: a stale check // through the HANDLE's Arc is rejected, proving one floor, not two. diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 7737604495..7fe4a5f667 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -374,10 +374,15 @@ async fn nip11_or_ws_handler( .into_response(); } }; - let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + let corporate_identity_jwt = match crate::corporate_identity::identity_jwt_from_headers( &headers, &state.config.corporate_identity, - ); + ) { + Ok(jwt) => jwt, + Err(_) => { + return (StatusCode::UNAUTHORIZED, "invalid identity assertion").into_response(); + } + }; let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 6271d6cdec..4cc0da398b 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -429,6 +429,18 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Compatibility seam for retaining protected session authority. + /// + /// No protected runtime is installable in this lower review unit, so the + /// session-conformance slice replaces this no-op with expiry and + /// invalidation retention before production installation becomes possible. + pub fn retain_protected_session_authority( + &self, + _conn_id: Uuid, + _authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + ) { + } + /// Sends a text message to the given connection. /// /// Returns `false` if the connection is gone or the buffer is full. @@ -628,6 +640,22 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, + /// Optional exact-domain protected transport, unset by this review unit. + pub protected_transport: Arc< + std::sync::OnceLock< + Arc, + >, + >, + /// Deployment-owned assertion provenance, unset by the stock binary. + pub identity_assertion_provenance: Arc< + std::sync::OnceLock< + Arc, + >, + >, + /// Independent restore witness, unavailable until its owning slice. + pub restore_protection: Arc< + std::sync::OnceLock>, + >, } impl AppState { @@ -804,6 +832,9 @@ impl AppState { // `crates/buzz-test-client` once those land). tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), + protected_transport: Arc::new(std::sync::OnceLock::new()), + identity_assertion_provenance: Arc::new(std::sync::OnceLock::new()), + restore_protection: Arc::new(std::sync::OnceLock::new()), }; ( state, @@ -820,6 +851,42 @@ impl AppState { self.mesh.get() } + /// Current protected transport, if a later composition root installed it. + pub fn protected_transport( + &self, + ) -> Option<&Arc> { + self.protected_transport.get() + } + + /// Install immutable deployment-owned assertion provenance. + pub fn install_identity_assertion_provenance( + &self, + verifier: Arc, + ) -> Result<(), Arc> { + self.identity_assertion_provenance.set(verifier) + } + + /// Return deployment-owned assertion provenance, if installed. + pub fn identity_assertion_provenance( + &self, + ) -> Option<&Arc> { + self.identity_assertion_provenance.get() + } + + /// Current independent restore witness, if installed by its owning slice. + pub fn restore_protection( + &self, + ) -> Option<&Arc> { + self.restore_protection.get() + } + + /// Whether an exact domain is in authoritative protected enforcement. + pub fn is_protected_enforcing(&self, domain: CommunityId) -> bool { + self.protected_transport() + .and_then(|runtime| runtime.mode_for_domain(domain)) + == Some(crate::authorization_runtime::finalization::AuthorizationMode::Enforce) + } + /// Record an event ID as locally-published for dedup, scoped to the /// community it was fanned out in. Called before Redis publish so the /// multi-node consumer can skip the echo for *this* community only — a From 0f2e595dc195c13db360983150a82f5f4fef37db Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:23:39 -0500 Subject: [PATCH 06/11] fix(auth): acknowledge deferred O4AB interfaces Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/src/audio/join.rs | 1 + crates/buzz-relay/src/authorization_runtime/executor.rs | 2 ++ crates/buzz-relay/src/authorization_runtime/transport.rs | 7 +++++++ crates/buzz-relay/src/corporate_identity.rs | 7 +++++++ 4 files changed, 17 insertions(+) diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index 1060f19d40..066f7d33ae 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -1271,6 +1271,7 @@ impl HuddleControlAcceptor { } /// Require current database authority for protected peer confirmation. + #[allow(dead_code)] pub(crate) fn with_authority_verifier( mut self, verifier: crate::authorization_runtime::ephemeral::AuthorityTokenVerifier, diff --git a/crates/buzz-relay/src/authorization_runtime/executor.rs b/crates/buzz-relay/src/authorization_runtime/executor.rs index ad1f6e98e6..d4f9ffca24 100644 --- a/crates/buzz-relay/src/authorization_runtime/executor.rs +++ b/crates/buzz-relay/src/authorization_runtime/executor.rs @@ -31,11 +31,13 @@ impl fmt::Debug for AuthorizationCommitFence { } /// Opaque ephemeral claim that cannot be constructed in this review unit. +#[allow(dead_code)] #[derive(Clone, serde::Deserialize, serde::Serialize)] pub(crate) struct EphemeralAuthorityClaim { _private: (), } +#[allow(dead_code)] impl EphemeralAuthorityClaim { pub(super) fn from_authority( _authority: &ProtectedOperationAuthority, diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs index d497b4fb42..903f5673cc 100644 --- a/crates/buzz-relay/src/authorization_runtime/transport.rs +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -311,12 +311,14 @@ pub trait ProtectedAuthorizationResolver: Send + Sync { } /// Display-only result retained with its exact invalidation observer. +#[allow(dead_code)] pub struct ProtectedStatusResolution { disposition: VerificationOnlyDisposition, observer: Arc, evaluation_generation: u64, } +#[allow(dead_code)] impl ProtectedStatusResolution { /// Couple one current status to the invalidation fence captured for it. pub fn new( @@ -818,10 +820,12 @@ pub struct ProtectedEnrollmentAuthority { } impl ProtectedEnrollmentAuthority { + #[allow(dead_code)] pub(super) const fn disposition(&self) -> &EnrollmentDisposition { &self.disposition } + #[allow(dead_code)] pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { self.observer.as_ref() } @@ -919,6 +923,7 @@ impl ProtectedAuthorization { /// Seal opaque sender authority for one ephemeral event delivered across /// the trusted relay Redis plane. The resulting claim still requires a /// relay signature and writer-database revalidation on the receiving pod. + #[allow(dead_code)] pub(crate) fn seal_ephemeral_delivery( &self, event_id: [u8; 32], @@ -981,10 +986,12 @@ pub struct ProtectedOperationAuthority { } impl ProtectedOperationAuthority { + #[allow(dead_code)] pub(super) fn context(&self) -> &AuthContext { &self.context } + #[allow(dead_code)] pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { self.observer.as_ref() } diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 01a0486227..bb19cd7e28 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -44,7 +44,9 @@ const JWKS_MAX_RESPONSE_BYTES: usize = 1024 * 1024; const JWT_CLOCK_SKEW_LEEWAY_SECS: u64 = 60; const IDENTITY_ASSERTION_MAX_TTL_SECS: u64 = 60 * 60; const IDENTITY_SESSION_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); +#[allow(dead_code)] const PUBLIC_PROJECTION_RECONCILIATION_INTERVAL: Duration = Duration::from_secs(1); +#[allow(dead_code)] const PUBLIC_PROJECTION_STARTUP_LIMIT: usize = 4096; #[derive(Debug, Clone)] @@ -1298,6 +1300,7 @@ fn identity_assertion_matches( } } +#[allow(dead_code)] fn identity_assertion_has_base_shape( event: &Event, relay_author: PublicKey, @@ -1406,6 +1409,7 @@ fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64 } /// Internal O4 reconciliation failure. This carries no identity or token data. +#[allow(dead_code)] #[derive(Debug, Error)] pub(crate) enum PublicProjectionReconciliationError { /// Durable projection state was unavailable or inconsistent. @@ -1431,6 +1435,7 @@ pub(crate) enum PublicProjectionReconciliationError { Incomplete, } +#[allow(dead_code)] async fn reconcile_one_public_projection( state: &AppState, domains: &[CommunityId], @@ -1575,6 +1580,7 @@ async fn reconcile_one_public_projection( } /// Drain every materialized retirement before protected routes become reachable. +#[allow(dead_code)] pub(crate) async fn reconcile_public_projection_retirements_startup( state: &AppState, domains: &[CommunityId], @@ -1597,6 +1603,7 @@ pub(crate) async fn reconcile_public_projection_retirements_startup( } /// Continuously discover committed lifecycle rows and retry withdrawal/delivery. +#[allow(dead_code)] pub(crate) async fn run_public_projection_retirement_reconciliation( state: Arc, domains: Vec, From 3a64d09af66a2234994a8c509dfb846f1f623233 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:53:02 -0500 Subject: [PATCH 07/11] fix(auth): complete O4AB migration and test adapters Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/src/corporate_identity.rs | 15 +++++++++++---- .../0036_protected_community_lifecycle_guard.sql | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index bb19cd7e28..ed48f4234c 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -3147,9 +3147,15 @@ mod tests { ) .await .expect("commit active projection"); - db.revoke_identity_key(community, subject.as_bytes(), None, "synthetic revocation") - .await - .expect("revoke synthetic key"); + db.revoke_identity_key( + community, + buzz_db::identity_lifecycle::LifecycleOperationId::issue(), + subject.as_bytes(), + subject.as_bytes(), + "synthetic revocation", + ) + .await + .expect("revoke synthetic key"); let observational = make_community(&pool).await; let observational_keys = Keys::generate(); @@ -3191,8 +3197,9 @@ mod tests { .expect("commit observational projection"); db.revoke_identity_key( observational, + buzz_db::identity_lifecycle::LifecycleOperationId::issue(), + observational_subject.as_bytes(), observational_subject.as_bytes(), - None, "observational revocation", ) .await diff --git a/migrations/0036_protected_community_lifecycle_guard.sql b/migrations/0036_protected_community_lifecycle_guard.sql index 810e372e94..02b1a1b88c 100644 --- a/migrations/0036_protected_community_lifecycle_guard.sql +++ b/migrations/0036_protected_community_lifecycle_guard.sql @@ -3,6 +3,16 @@ -- so a stale database restore cannot resurrect a protected community. Off and -- observational communities retain their existing lifecycle behavior. +-- The protected-domain marker must exist before this migration installs the +-- lifecycle guard. Later invalidation migrations extend this durable marker; +-- they must not be prerequisites for the guard that protects it. +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL REFERENCES communities(id), + generation BIGINT NOT NULL DEFAULT 0 CHECK (generation >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id) +); + CREATE FUNCTION deny_unwitnessed_protected_community_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN From 5727152d4eece55ee621d46475372af4a74b9bb3 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:03:30 -0500 Subject: [PATCH 08/11] fix(auth): order O4AB migrations safely Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- ...sql => 0039_audio_admission_visibility.sql} | 0 migrations/0039_git_policy_authority_epoch.sql | 18 ------------------ 2 files changed, 18 deletions(-) rename migrations/{0040_audio_admission_visibility.sql => 0039_audio_admission_visibility.sql} (100%) delete mode 100644 migrations/0039_git_policy_authority_epoch.sql diff --git a/migrations/0040_audio_admission_visibility.sql b/migrations/0039_audio_admission_visibility.sql similarity index 100% rename from migrations/0040_audio_admission_visibility.sql rename to migrations/0039_audio_admission_visibility.sql diff --git a/migrations/0039_git_policy_authority_epoch.sql b/migrations/0039_git_policy_authority_epoch.sql deleted file mode 100644 index dcd9c8b3cc..0000000000 --- a/migrations/0039_git_policy_authority_epoch.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Kind 30617 is live Git authorization policy. Its replacement or deletion --- must advance the independently witnessed PostgreSQL authority vector so a --- stale restore cannot revive an earlier, more permissive policy. - -CREATE TRIGGER git_policy_insert_authority_epoch - AFTER INSERT ON events - FOR EACH ROW WHEN (NEW.kind = 30617) - EXECUTE FUNCTION advance_authorization_authority_epoch(); - -CREATE TRIGGER git_policy_update_authority_epoch - AFTER UPDATE ON events - FOR EACH ROW WHEN (OLD.kind = 30617 OR NEW.kind = 30617) - EXECUTE FUNCTION advance_authorization_authority_epoch(); - -CREATE TRIGGER git_policy_delete_authority_epoch - AFTER DELETE ON events - FOR EACH ROW WHEN (OLD.kind = 30617) - EXECUTE FUNCTION advance_authorization_authority_epoch(); From d1a866d313761e527d3b52bbe4b003909c850454 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:10:10 -0500 Subject: [PATCH 09/11] fix(db): align O4AB migrator count Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 21eb67873c..c6f83ff226 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -566,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 40); + assert_eq!(migrations.len(), 39); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] From bd277a471cdbc4176d6ac317994cf94b04bb5b69 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:21:37 -0500 Subject: [PATCH 10/11] fix(db): close audio visibility migration dependencies Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-db/src/migration.rs | 43 +++++++++++++++++++ .../0039_audio_admission_visibility.sql | 25 ++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index c6f83ff226..50e2d5d002 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -2559,6 +2559,49 @@ mod tests { .expect("retry additive projection after rollback"); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audio_visibility_0039_applies_from_empty_database_without_later_objects() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + + MIGRATOR + .run_to(39, &pool) + .await + .expect("apply migrations through audio visibility"); + + assert_eq!(applied_versions(&pool).await.last().copied(), Some(39)); + let admission_columns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.columns \ + WHERE table_schema='public' AND table_name='audio_session_admissions' \ + AND column_name IN ('claimant_id', 'attachment_generation', \ + 'claim_expires_at', 'visibility_observed_at')", + ) + .fetch_one(&pool) + .await + .expect("inspect audio admission columns"); + assert_eq!(admission_columns, 4); + + let admission_constraints: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_constraint \ + WHERE conrelid='audio_session_admissions'::regclass \ + AND conname IN ('audio_session_admissions_state', \ + 'audio_session_admissions_claim', \ + 'audio_session_admissions_visibility')", + ) + .fetch_one(&pool) + .await + .expect("inspect audio admission constraints"); + assert_eq!(admission_constraints, 3); + + let later_authority_table: Option = + sqlx::query_scalar("SELECT to_regclass('authorization_authority_epochs')::text") + .fetch_one(&pool) + .await + .expect("inspect later authority table"); + assert_eq!(later_authority_table, None); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { diff --git a/migrations/0039_audio_admission_visibility.sql b/migrations/0039_audio_admission_visibility.sql index 7eaaba11bc..7ea926baf9 100644 --- a/migrations/0039_audio_admission_visibility.sql +++ b/migrations/0039_audio_admission_visibility.sql @@ -2,11 +2,32 @@ -- `active` remains authorization for an attempt, never proof of presence. ALTER TABLE audio_session_admissions + ADD COLUMN claimant_id UUID, + ADD COLUMN attachment_generation BIGINT NOT NULL DEFAULT 0 + CHECK (attachment_generation >= 0), + ADD COLUMN claim_expires_at TIMESTAMPTZ, ADD COLUMN visibility_observed_at TIMESTAMPTZ; +-- A pre-cutover process-local attachment cannot be reconstructed safely. End +-- every nonterminal attempt fail-closed, while assigning a stable migration +-- claimant only to already-terminal history so the final visibility-aware +-- invariant is additive on populated databases. +UPDATE audio_session_admissions +SET state = 'aborted', + state_version = state_version + 1, + aborted_at = COALESCE(aborted_at, clock_timestamp()), + updated_at = clock_timestamp(), + failure_code = 'upgrade_reconciliation' +WHERE state IN ('reserved', 'active'); + +UPDATE audio_session_admissions +SET claimant_id = admission_id, + attachment_generation = 1, + claim_expires_at = lease_expires_at +WHERE state = 'finished'; + ALTER TABLE audio_session_admissions - DROP CONSTRAINT audio_session_admissions_state, - DROP CONSTRAINT audio_session_admissions_claim; + DROP CONSTRAINT audio_session_admissions_state; ALTER TABLE audio_session_admissions ADD CONSTRAINT audio_session_admissions_state From 18dbda7dcefe05ef6cafe78f8fd4e8afd9c30fee Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:51:48 -0500 Subject: [PATCH 11/11] fix(schema): install protected visibility prerequisites Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- schema/schema.sql | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/schema/schema.sql b/schema/schema.sql index 11344d37b3..2436644919 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -64,6 +64,53 @@ CREATE TABLE communities ( CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); +-- ── Git repo name registry (NIP-34 kind:30617) ─────────────────────────────── +-- Desired-state equivalent of additive migrations 0002 and 0034. Repository +-- names are scoped to their community, and protected unpublished reservations +-- remain distinguishable from legacy reservations that require an object-store +-- pointer. + +CREATE TABLE git_repo_names ( + community_id UUID NOT NULL REFERENCES communities(id), + repo_id TEXT NOT NULL, + owner_pubkey TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + publication_origin TEXT NOT NULL DEFAULT 'legacy' + CHECK (publication_origin IN ('legacy', 'protected_unpublished')), + PRIMARY KEY (community_id, repo_id) +); + +CREATE INDEX idx_git_repo_names_owner + ON git_repo_names (community_id, owner_pubkey); + +-- ── Protected object authority ─────────────────────────────────────────────── +-- Desired-state equivalent of additive migration 0033. Even in legacy mode, +-- every Git/media visibility write acquires this durable migration fence before +-- touching an object-store pointer or sidecar. + +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + surface TEXT NOT NULL CHECK (surface IN ('git', 'media')), + state TEXT NOT NULL CHECK (state IN ('legacy', 'importing', 'postgresql')), + generation BIGINT NOT NULL CHECK (generation > 0), + imported_objects BIGINT NOT NULL DEFAULT 0 CHECK (imported_objects >= 0), + inventory_sha256 TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, surface), + CHECK (inventory_sha256 IS NULL OR inventory_sha256 ~ '^[0-9a-f]{64}$'), + CHECK ( + (state = 'legacy' AND started_at IS NULL AND completed_at IS NULL) + OR (state = 'importing' AND started_at IS NOT NULL AND completed_at IS NULL) + OR (state = 'postgresql' AND started_at IS NOT NULL AND completed_at IS NOT NULL + AND inventory_sha256 IS NOT NULL) + ) +); + +CREATE INDEX idx_protected_object_authority_state + ON protected_object_authority (state, community_id, surface); + -- ── Channels ────────────────────────────────────────────────────────────────── -- Conformance: "Channels and channel membership". `community_id` immutable. -- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique: