diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f46ccd4f..1efd0275a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,6 +356,7 @@ jobs: -p buzz-relay \ -p buzz-test-client \ --lib \ + --test nip_fi_runtime_conformance \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache @@ -713,6 +714,20 @@ jobs: --run-ignored all env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests + - name: NIP-FI runtime and protected transport conformance + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'binary(nip_fi_runtime_conformance)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and (test(protected_media_reads_require_corporate_identity_for_get_and_head) or test(moderation_reads_require_corporate_identity_after_nip98_proof))' \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 - name: Workspace profile (kind:9033) gate tests # Call-site integration for the 9033 authorization gate: open relay # rosterless/steward transitions and the closed-relay admin/owner rule, @@ -725,6 +740,20 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Protected Git and media authority migration tests + run: | + docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \ + psql -U buzz -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE buzz_visibility_tests" + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E '(package(buzz-db) and test(/protected_visibility::tests::cutover_waits/)) or (package(buzz-relay) and test(/api::media_migration::tests::populated_git_and_media_cutover/))' \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests + REDIS_URL: redis://localhost:6379 - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against 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/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index e9ca0e9c9e..9c0727daef 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -185,7 +185,6 @@ impl ResolvedFederatedPolicy { pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { Self { stamp } } - #[cfg(test)] pub(crate) fn not_required(authorization_domain: CommunityId) -> Self { Self::from_authoritative_resolution( @@ -601,6 +600,42 @@ 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( @@ -697,7 +732,7 @@ impl VersionedBindingRef { } /// Stable reason proven by the authoritative binding lifecycle result. - pub(super) const fn authorization_reason(&self) -> AuthorizationReason { + pub(crate) const fn authorization_reason(&self) -> AuthorizationReason { self.resolution_reason } } diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs index c99018a4b0..b075c9c8b3 100644 --- a/crates/buzz-auth/src/context/evidence.rs +++ b/crates/buzz-auth/src/context/evidence.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{fmt, sync::Arc}; use buzz_core::CommunityId; use nostr::PublicKey; @@ -6,9 +6,7 @@ use uuid::Uuid; use crate::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, @@ -434,9 +454,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 +466,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 +518,74 @@ 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) + } +} + +/// Exact authority-defined identity of one verified delegated relationship. +/// +/// This identifier must come from the successful delegation verifier. It is +/// deliberately distinct from owner, delegate, and identity-binding IDs so a +/// broad principal selector cannot stand in for one signed relationship. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct DelegatedRelationshipId(Uuid); + +impl DelegatedRelationshipId { + pub(crate) fn new(value: Uuid) -> Result { + if value.is_nil() { + return Err(AuthContextError::InvalidDelegatedRelationshipId); + } + Ok(Self(value)) + } + + /// Opaque stable relationship identifier. + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl fmt::Debug for DelegatedRelationshipId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegatedRelationshipId") + .field(&"[redacted]") + .finish() + } +} + +/// Monotonic authority revision for one delegated relationship. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DelegatedRelationshipRevision(u64); + +impl DelegatedRelationshipRevision { + /// Initial revision of an immutable signed relationship issuance. + pub const INITIAL: Self = Self(1); + + pub(crate) fn new(value: u64) -> Result { + if value == 0 { + return Err(AuthContextError::InvalidDelegatedRelationshipRevision); + } + Ok(Self(value)) + } + + /// Positive monotonic relationship revision. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for DelegatedRelationshipRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegatedRelationshipRevision") + .field(&"[redacted]") + .finish() + } +} + /// Transport-wide delegation from a bound owner to the authenticated key. /// /// A verifier may construct this only after proving the capability authorizes @@ -503,6 +598,8 @@ impl fmt::Debug for DelegationCapability { pub struct VerifiedTransportDelegation { owner_pubkey: PublicKey, delegate_pubkey: PublicKey, + relationship_id: DelegatedRelationshipId, + relationship_revision: DelegatedRelationshipRevision, capability: DelegationCapability, expires_at: Option, } @@ -513,6 +610,8 @@ impl fmt::Debug for VerifiedTransportDelegation { .debug_struct("VerifiedTransportDelegation") .field("owner_pubkey", &"[redacted]") .field("delegate_pubkey", &"[redacted]") + .field("relationship_id", &"[redacted]") + .field("relationship_revision", &"[redacted]") .field("capability", &"[redacted]") .field("expires_at", &"[redacted]") .finish() @@ -522,10 +621,11 @@ 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, + relationship_id: Uuid, + relationship_revision: u64, expires_at: Option, ) -> Result { if owner_pubkey == delegate_pubkey { @@ -534,6 +634,8 @@ impl VerifiedTransportDelegation { Ok(Self { owner_pubkey, delegate_pubkey, + relationship_id: DelegatedRelationshipId::new(relationship_id)?, + relationship_revision: DelegatedRelationshipRevision::new(relationship_revision)?, capability: DelegationCapability::TransportWide, expires_at, }) @@ -549,6 +651,16 @@ impl VerifiedTransportDelegation { self.delegate_pubkey } + /// Exact verifier-defined delegated-relationship identity. + pub const fn relationship_id(&self) -> DelegatedRelationshipId { + self.relationship_id + } + + /// Exact monotonic revision of the delegated relationship. + pub const fn relationship_revision(&self) -> DelegatedRelationshipRevision { + self.relationship_revision + } + /// Verified capability scope. pub const fn capability(&self) -> DelegationCapability { self.capability @@ -560,6 +672,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 +758,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 +815,7 @@ impl VerifiedNostrProof { authorized_transport, actor_pubkey, proof_method, + operation_binding: VerifiedOperationBinding::for_transport(authorized_transport), verified_delegation, }) } @@ -624,6 +840,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 +859,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 +904,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..a49b8ee3cb 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; @@ -31,9 +37,11 @@ pub use binding::{ }; pub use evidence::{ AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, - AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, - FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, - VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, + AuthTransport, AuthorizedCommunityAccess, DelegatedRelationshipId, + DelegatedRelationshipRevision, DelegationCapability, DelegationExpiry, FederatedPrincipal, + NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, + VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission, + 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,82 @@ 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); + if !matches!( + federated_policy.requirement(), + FederatedIdentityRequirement::NotRequired + ) || !matches!(authorization, FederatedAuthorization::NotRequired) + { + return Err(AuthContextError::ProviderDecisionRequired); } - 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 +439,7 @@ impl AuthContext { nostr, federated_policy, federated: authorization, + authorization_lease, scopes, channel_ids, })) @@ -394,17 +481,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 +509,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 +598,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 +650,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..973b490e31 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -79,6 +79,12 @@ pub enum AuthContextError { /// Delegation expiry was not a valid Unix timestamp. #[error("delegation expiry must be greater than zero")] InvalidDelegationExpiry, + /// Delegated-relationship identity was the nil UUID. + #[error("delegated relationship identifier must not be nil")] + InvalidDelegatedRelationshipId, + /// Delegated-relationship revision was zero. + #[error("delegated relationship revision must be greater than zero")] + InvalidDelegatedRelationshipRevision, /// Admission expiry was not a valid Unix timestamp. #[error("admission expiry must be greater than zero")] InvalidAdmissionExpiry, @@ -154,6 +160,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 +181,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 { @@ -189,6 +204,10 @@ impl AuthContextError { Self::InvalidFederatedPolicyInterval => "federated_policy_invalid_interval", Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry", Self::InvalidDelegationExpiry => "delegation_invalid_expiry", + Self::InvalidDelegatedRelationshipId => "delegation_invalid_relationship_id", + Self::InvalidDelegatedRelationshipRevision => { + "delegation_invalid_relationship_revision" + } Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry", Self::AssertionExpired => "federated_assertion_expired", Self::BindingExpired => "federated_binding_expired", @@ -214,6 +233,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 +242,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..3ed1c00b20 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -288,6 +288,8 @@ fn input_with_delegation_expiry( VerifiedTransportDelegation::new_unrestricted( owner_pubkey, actor_pubkey, + Uuid::from_u128(0x601), + 1, Some( DelegationExpiry::new(delegation_expiry) .expect("synthetic delegation expiry is valid"), @@ -353,7 +355,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 +400,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 +417,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 +439,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 +484,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,16 +524,33 @@ 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]\" })" ) ); } +#[test] +fn public_federated_finalization_requires_provider_decision() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("federated context cannot bypass provider finalization"); + assert_eq!(error, AuthContextError::ProviderDecisionRequired); +} + #[test] 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 +572,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 +591,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 +619,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 +638,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, @@ -643,6 +662,8 @@ fn verified_nostr_proof_requires_the_authenticated_delegate() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), other_delegate.public_key(), + Uuid::from_u128(0x602), + 1, None, ) .expect("synthetic owner and delegate are distinct"); @@ -663,7 +684,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 +703,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 +853,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 { @@ -894,7 +915,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 +940,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 +963,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 +1002,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 +1059,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 +1075,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 +1096,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 +1125,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 +1161,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, @@ -1736,7 +1757,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 +1790,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 +1829,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 +1854,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, @@ -1835,9 +1876,14 @@ fn nostr_only_authorization_may_preserve_a_verified_owner() { #[test] fn transport_delegation_rejects_self_reference() { let actor = Keys::generate(); - let error = - VerifiedTransportDelegation::new_unrestricted(actor.public_key(), actor.public_key(), None) - .expect_err("an actor cannot be its own verified owner"); + let error = VerifiedTransportDelegation::new_unrestricted( + actor.public_key(), + actor.public_key(), + Uuid::from_u128(0x603), + 1, + None, + ) + .expect_err("an actor cannot be its own verified owner"); assert_eq!(error, AuthContextError::SelfDelegation); } @@ -1849,6 +1895,8 @@ fn transport_delegation_is_explicitly_transport_wide() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), delegate.public_key(), + Uuid::from_u128(0x604), + 1, None, ) .expect("synthetic owner and delegate are distinct"); @@ -1859,7 +1907,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 +1922,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 +1940,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 +1960,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 +1975,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 +1995,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 +2019,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 +2044,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..982af819c5 --- /dev/null +++ b/crates/buzz-auth/src/evidence_adapter.rs @@ -0,0 +1,652 @@ +//! 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, + VerifiedTransportDelegation, VersionedBindingRef, + }, + nip42::verify_nip42_event, + nip98::verify_nip98_event, + AuthorizationReason, 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, + relationship_id: Uuid, + relationship_revision: u64, + 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, + relationship_id: Uuid, + relationship_revision: u64, + expires_at: Option, + transport_wide: bool, + ) -> Self { + Self { + owner_pubkey, + delegate_pubkey, + relationship_id, + relationship_revision, + 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) + } + + /// 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, + 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, + output.relationship_id, + output.relationship_revision, + 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, + )) + } + + /// 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), + /// Existing full Blossom operation verification failed. + #[error(transparent)] + Blossom(#[from] crate::blossom::BlossomAuthError), + /// 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, + /// 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, Kind, RelayUrl, Tag, Timestamp}; + + use super::*; + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + #[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(), + Uuid::from_u128(0x701), + 1, + 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(), + Uuid::from_u128(0x702), + 1, + 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) + )); + } + + #[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/finalization.rs b/crates/buzz-auth/src/finalization.rs new file mode 100644 index 0000000000..d3842894ec --- /dev/null +++ b/crates/buzz-auth/src/finalization.rs @@ -0,0 +1,1057 @@ +//! Federated authorization finalization. +//! +//! This is the only crate-owned path from a validated provider capability +//! snapshot to an access lease. Display-only verification is returned as a +//! separate type that cannot be converted into an [`crate::AuthContext`] or +//! consumed by protected-operation lease validation. + +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{ + validate_context_evidence, AuthContext, AuthContextError, AuthContextInput, BindingVersion, + FederatedAuthorization, FederatedIdentityRequirement, ResolvedFederatedPolicy, + VersionedBindingRef, + }, + lease::{ + conservative_expiry, AccessLeasePolicy, AuthorizationClockError, AuthorizationLease, + AuthorizationTime, BindingLeaseBound, LeaseIssueError, LeaseVersion, + SharedAuthorizationClock, VerificationStatusPolicy, + }, + provider::{AuthorizationProfileId, CapabilitySnapshot, DecisionSource, PolicyVersion}, +}; + +/// Finalizer using one centrally injected clock for all authorization time. +#[derive(Clone)] +pub struct AuthorizationFinalizer { + clock: SharedAuthorizationClock, +} + +impl AuthorizationFinalizer { + /// Create a finalizer backed by the supplied central authorization clock. + pub fn new(clock: SharedAuthorizationClock) -> Self { + Self { clock } + } + + /// Read current time from the injected authorization clock. + pub fn now(&self) -> Result { + self.clock.now() + } + + /// Finalize enforcing federated authority and issue one bounded lease. + /// + /// The provider snapshot must be the current allow decision for the exact + /// domain, actor, transport, principal, profile, and correlation ID. The + /// resulting lease expires at the earliest provider/identity bound, + /// binding freshness bound, or configured application maximum, shortened + /// by the explicit conservative clock skew. + #[allow(clippy::too_many_arguments)] + pub fn finalize_access( + &self, + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + expected_profile: &AuthorizationProfileId, + binding_bound: BindingLeaseBound, + lease_policy: AccessLeasePolicy, + lease_version: LeaseVersion, + ) -> Result { + let now = self.now()?; + validate_context_evidence( + &input, + &federated_policy, + &authorization, + now.unix_seconds(), + )?; + let active_binding = validate_provider_evidence( + &input, + &federated_policy, + &authorization, + &snapshot, + expected_profile, + &binding_bound, + now, + )?; + let lease = AuthorizationLease::issue( + lease_version, + snapshot.authorization_domain(), + snapshot.transport(), + snapshot.actor_pubkey(), + snapshot.owner_pubkey(), + active_binding, + binding_bound, + snapshot.profile_id().clone(), + snapshot.policy_version().clone(), + snapshot.capabilities().clone(), + snapshot.effective_until(), + now, + lease_policy, + snapshot.correlation_id(), + )?; + Ok(AuthContext::finalize_v1_with_lease( + input, + federated_policy, + authorization, + lease, + now.unix_seconds(), + )?) + } + + /// Finalize display-only verification without issuing access authority. + /// + /// This path requires a direct active binding for the event-author key. + /// The returned type carries no capability set, access lease, membership, + /// or conversion into an authorized context. + #[allow(clippy::too_many_arguments)] + pub fn finalize_verification_only( + &self, + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + expected_profile: &AuthorizationProfileId, + binding_bound: BindingLeaseBound, + status_policy: VerificationStatusPolicy, + ) -> Result { + let now = self.now()?; + validate_context_evidence( + &input, + &federated_policy, + &authorization, + now.unix_seconds(), + )?; + let active_binding = validate_provider_evidence( + &input, + &federated_policy, + &authorization, + &snapshot, + expected_profile, + &binding_bound, + now, + )?; + if !matches!(authorization, FederatedAuthorization::Direct { .. }) { + return Err(FinalizationError::VerificationRequiresDirectBinding); + } + let expires_at = conservative_expiry( + now, + snapshot.effective_until(), + binding_bound.valid_until(), + status_policy.application_limit(), + status_policy.clock_skew(), + )?; + Ok(VerificationOnlyDisposition { + authorization_domain: snapshot.authorization_domain(), + actor_pubkey: snapshot.actor_pubkey(), + binding_id: active_binding.binding_id(), + binding_version: active_binding.binding_version(), + profile_id: snapshot.profile_id().clone(), + policy_version: snapshot.policy_version().clone(), + correlation_id: snapshot.correlation_id(), + issued_at: now.unix_seconds(), + expires_at, + }) + } +} + +impl fmt::Debug for AuthorizationFinalizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationFinalizer") + .field("clock", &"[injected]") + .finish() + } +} + +/// Short-lived, display-only proof of a current direct binding. +/// +/// This type is deliberately not an authorization context or lease. Protected +/// operations accept [`AuthorizationLease`], so a +/// verification-only result cannot grant access even if a caller retains it. +#[must_use] +#[derive(PartialEq, Eq)] +pub struct VerificationOnlyDisposition { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + correlation_id: Uuid, + issued_at: u64, + expires_at: u64, +} + +impl VerificationOnlyDisposition { + /// Exact authorization domain represented by the display status. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Event-author key whose direct active binding was verified. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Stable direct 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 + } + + /// Current opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Correlation identifier for the display-only decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Central issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Conservative display-status expiry in Unix seconds. + pub const fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Check display freshness using the supplied central authorization clock. + /// + /// This check only controls presentation and is not an access decision. + pub fn is_current( + &self, + clock: &dyn crate::AuthorizationClock, + ) -> Result { + let now = clock.now()?; + Ok(now.unix_seconds() >= self.issued_at && now.unix_seconds() < self.expires_at) + } +} + +impl fmt::Debug for VerificationOnlyDisposition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerificationOnlyDisposition") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +fn validate_provider_evidence<'a>( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + authorization: &'a FederatedAuthorization, + snapshot: &CapabilitySnapshot, + expected_profile: &AuthorizationProfileId, + binding_bound: &BindingLeaseBound, + now: AuthorizationTime, +) -> Result<&'a VersionedBindingRef, FinalizationError> { + if !matches!( + federated_policy.requirement(), + FederatedIdentityRequirement::Required(_) + ) { + return Err(FinalizationError::FederatedPolicyRequired); + } + let domain = input.tenant().community(); + let proof = input.nostr_proof(); + if federated_policy.authorization_domain() != domain + || !snapshot.is_bound_to_federated_policy(federated_policy) + || snapshot.authorization_domain() != domain + || snapshot.transport() != proof.authorized_transport() + || snapshot.actor_pubkey() != proof.actor_pubkey() + || snapshot.proof_method() != proof.proof_method() + || snapshot.correlation_id() != input.correlation_id() + || snapshot.profile_id() != expected_profile + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + if snapshot.issued_at() > now.unix_seconds() + || snapshot.fresh_until() <= now.unix_seconds() + || snapshot.effective_until() <= now.unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceStale); + } + let active_binding = authorization + .active_binding() + .ok_or(FinalizationError::FederatedAuthorizationRequired)?; + if active_binding.binding_id() != binding_bound.binding_id() + || active_binding.binding_version() != binding_bound.binding_version() + { + return Err(FinalizationError::BindingEvidenceMismatch); + } + match authorization { + FederatedAuthorization::NotRequired => { + return Err(FinalizationError::FederatedAuthorizationRequired); + } + FederatedAuthorization::Direct { binding, assertion } => { + if snapshot.decision_source() != DecisionSource::DirectAssertion + || snapshot.owner_pubkey().is_some() + || snapshot.binding_id().is_some() + || snapshot.binding_version().is_some() + || snapshot.principal() != binding.principal() + || snapshot.principal() != assertion.principal() + || snapshot.effective_until() > assertion.expires_at().unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + } + FederatedAuthorization::Delegated { owner, admission } => { + if !proof + .verified_delegation() + .is_some_and(|delegation| delegation.capability().is_transport_wide()) + { + return Err(FinalizationError::UnsupportedDelegationScope); + } + if snapshot.decision_source() != DecisionSource::DelegatedOwnerBinding + || snapshot.owner_pubkey() != Some(owner.bound_pubkey()) + || snapshot.binding_id() != Some(owner.binding_id()) + || snapshot.binding_version() != Some(owner.binding_version()) + || snapshot.principal() != owner.principal() + || snapshot.principal() != admission.principal() + || snapshot.fresh_until() != admission.fresh_until().unix_seconds() + { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + if let Some(delegation_expiry) = proof + .verified_delegation() + .and_then(|delegation| delegation.expires_at()) + { + if snapshot.effective_until() > delegation_expiry.unix_seconds() { + return Err(FinalizationError::ProviderEvidenceMismatch); + } + } + } + } + Ok(active_binding) +} + +/// Fail-closed federated finalization error. +#[derive(Debug, Error)] +pub enum FinalizationError { + /// The central authorization clock failed. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// Existing context evidence was invalid. + #[error(transparent)] + Context(#[from] AuthContextError), + /// A bounded lease or display expiry could not be issued. + #[error(transparent)] + Lease(#[from] LeaseIssueError), + /// The server-resolved policy did not require federated authorization. + #[error("federated finalization requires server-resolved federated policy")] + FederatedPolicyRequired, + /// No direct or delegated active binding evidence was supplied. + #[error("federated finalization requires active binding authorization")] + FederatedAuthorizationRequired, + /// The provider snapshot did not match the exact finalization evidence. + #[error("provider decision does not match finalization evidence")] + ProviderEvidenceMismatch, + /// The provider snapshot was future-issued, stale, or expired. + #[error("provider decision is no longer current")] + ProviderEvidenceStale, + /// Binding freshness evidence named another binding or version. + #[error("binding freshness evidence does not match active binding")] + BindingEvidenceMismatch, + /// Display verification was attempted for delegated rather than direct authority. + #[error("verification-only display requires the event author's direct active binding")] + VerificationRequiresDirectBinding, + /// Narrower delegation evidence reached the transport-wide finalizer. + #[error("operation-bound delegation cannot be promoted to transport-wide authority")] + UnsupportedDelegationScope, +} + +impl FinalizationError { + /// Stable audit and metric code. + pub fn code(&self) -> &'static str { + match self { + Self::Clock(_) => "authorization_finalize_001", + Self::Context(error) => error.code(), + Self::Lease(error) => error.code(), + Self::FederatedPolicyRequired => "authorization_finalize_002", + Self::FederatedAuthorizationRequired => "authorization_finalize_003", + Self::ProviderEvidenceMismatch => "authorization_finalize_004", + Self::ProviderEvidenceStale => "authorization_finalize_005", + Self::BindingEvidenceMismatch => "authorization_finalize_006", + Self::VerificationRequiresDirectBinding => "authorization_finalize_007", + Self::UnsupportedDelegationScope => "authorization_finalize_008", + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + use std::time::Duration; + + use nostr::Keys; + + use super::*; + use crate::{ + context::{ + AssertionExpiry, AssertionTransport, AuthContextVersion, AuthMethod, AuthTransport, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, + AuthorizedCommunityAccess, BindingSource, DelegationExpiry, EnrollmentMode, + FederatedPolicyStamp, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedTransportDelegation, + }, + lease::{ + ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockSkew, + AuthorizationLeaseValidator, LeaseRenewalAction, LeaseRenewalLeadTime, + LeaseUseRequirement, + }, + provider::{ + resolve_authorization, AuthorizationCapability, AuthorizationOutcome, + AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, + CapabilitySet, ProviderAllow, ProviderDecision, ProviderTimeout, + }, + Scope, + }; + + struct FixedClock(AtomicU64); + + impl FixedClock { + fn new(now: u64) -> Self { + Self(AtomicU64::new(now)) + } + + fn set(&self, now: u64) { + self.0.store(now, Ordering::SeqCst); + } + } + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds( + self.0.load(Ordering::SeqCst), + )) + } + } + + impl crate::provider::AuthorizationClock for FixedClock { + fn now_unix_seconds(&self) -> Option { + Some(self.0.load(Ordering::SeqCst)) + } + } + + struct AllowProvider { + issued_at: u64, + fresh_until: u64, + } + + impl AuthorizationProvider for AllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + self.profile_id(), + request.requested_capabilities().clone(), + PolicyVersion::new("policy.synthetic.example") + .expect("synthetic policy version is valid"), + self.issued_at, + self.fresh_until, + ) + .expect("synthetic provider result is valid"); + Box::pin(std::future::ready(ProviderDecision::Allow(allow))) + } + } + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0x100)) + } + + fn profile() -> AuthorizationProfileId { + AuthorizationProfileId::from_server_configuration("profile.synthetic.example") + .expect("synthetic profile is valid") + } + + fn required_policy( + correlation_id: Uuid, + enrollment_mode: EnrollmentMode, + ) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(), + Uuid::from_u128(0x40), + 1, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + struct DirectFixture { + input: AuthContextInput, + policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + binding_bound: BindingLeaseBound, + binding_id: Uuid, + binding_version: BindingVersion, + actor_pubkey: PublicKey, + } + + fn direct_fixture(assertion_expiry: u64, binding_expiry: u64) -> DirectFixture { + let actor = Keys::generate().public_key(); + let principal = + crate::FederatedPrincipal::new("https://issuer.synthetic.example", "subject-synthetic") + .expect("synthetic principal is valid"); + let proof = VerifiedNostrProof::new( + domain(), + AuthTransport::RelayWebSocket, + actor, + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(), + AuthTransport::RelayWebSocket, + principal.clone(), + Some(VerifiedKeyAttestation::new(actor)), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(assertion_expiry).expect("synthetic expiry is valid"), + ); + let binding_id = Uuid::from_u128(0x200); + let binding_version = BindingVersion::new(7).expect("synthetic version is valid"); + let binding = VersionedBindingRef::new_existing_active_for_test( + domain(), + binding_id, + principal, + actor, + binding_version, + None, + BindingSource::AttestedKey, + ) + .expect("synthetic binding is valid"); + let binding_bound = BindingLeaseBound::new(&binding, binding_expiry) + .expect("synthetic binding bound is valid"); + let tenant = + buzz_core::tenant::TenantContext::resolved(domain(), "relay.synthetic.example"); + DirectFixture { + input: AuthContextInput::new( + tenant, + Uuid::from_u128(0x300), + proof, + AuthorizedCommunityAccess::new(domain(), vec![Scope::MessagesRead], None), + ), + policy: required_policy(Uuid::from_u128(0x300), EnrollmentMode::AttestedKey), + authorization: FederatedAuthorization::Direct { binding, assertion }, + binding_bound, + binding_id, + binding_version, + actor_pubkey: actor, + } + } + + async fn direct_snapshot( + fixture: &DirectFixture, + clock: &FixedClock, + provider_fresh_until: u64, + ) -> Box { + let FederatedAuthorization::Direct { assertion, .. } = &fixture.authorization else { + panic!("direct fixture must contain direct authorization"); + }; + let request = AuthorizationRequest::direct( + fixture.input.nostr_proof(), + assertion, + required_policy(fixture.input.correlation_id(), EnrollmentMode::AttestedKey), + CapabilitySet::single(AuthorizationCapability::CommunityRead), + fixture.input.correlation_id(), + 1_000, + ) + .expect("synthetic request is valid"); + let outcome = resolve_authorization( + &AllowProvider { + issued_at: 999, + fresh_until: provider_fresh_until, + }, + &request, + clock, + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), + ) + .await; + match outcome { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic provider must allow, got {other:?}"), + } + } + + fn access_policy(limit_seconds: u64) -> AccessLeasePolicy { + AccessLeasePolicy::new( + ApplicationLeaseLimit::from_seconds(limit_seconds) + .expect("synthetic application limit is valid"), + AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"), + ) + } + + #[tokio::test] + async fn direct_lease_carries_binding_and_earliest_application_expiry() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context + .authorization_lease() + .expect("enforcing context carries a lease"); + assert_eq!(lease.binding_id(), fixture.binding_id); + assert_eq!(lease.binding_version(), fixture.binding_version); + assert_eq!(lease.expires_at(), 1_095); + assert_eq!( + lease.capabilities().as_slice(), + &[AuthorizationCapability::CommunityRead] + ); + } + + #[test] + fn earliest_expiry_includes_provider_binding_application_and_skew() { + let now = AuthorizationTime::from_unix_seconds(1_000); + let limit = ApplicationLeaseLimit::from_seconds(500).expect("synthetic limit is valid"); + let skew = AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"); + assert_eq!( + conservative_expiry(now, 1_100, 1_200, limit, skew), + Ok(1_095) + ); + assert_eq!( + conservative_expiry(now, 1_300, 1_080, limit, skew), + Ok(1_075) + ); + let short_limit = + ApplicationLeaseLimit::from_seconds(60).expect("synthetic limit is valid"); + assert_eq!( + conservative_expiry(now, 1_300, 1_200, short_limit, skew), + Ok(1_055) + ); + } + + #[tokio::test] + async fn operation_guard_is_per_capability_and_revalidates_at_commit() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let validator = AuthorizationLeaseValidator::new(clock.clone()); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + let guard = context + .operation_guard(&validator, requirement) + .expect("exact capability creates an operation guard"); + guard + .revalidate() + .expect("guard remains valid before commit boundary"); + clock.set(lease.expires_at()); + assert!(matches!( + guard.revalidate(), + Err(crate::LeaseValidationError::Expired) + )); + } + + #[tokio::test] + async fn same_version_different_binding_is_denied() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id: Uuid::from_u128(0x201), + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + + assert_eq!( + AuthorizationLeaseValidator::new(clock).authorize(lease, &requirement), + Err(crate::LeaseValidationError::BindingIdMismatch) + ); + } + + #[tokio::test] + async fn same_policy_version_different_profile_is_denied() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::INITIAL, + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: AuthorizationProfileId::from_server_configuration( + "other-profile.synthetic.example", + ) + .expect("synthetic profile is valid"), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + + assert_eq!( + AuthorizationLeaseValidator::new(clock).authorize(lease, &requirement), + Err(crate::LeaseValidationError::AuthorizationProfileMismatch) + ); + } + + #[tokio::test] + async fn typed_version_policy_and_capability_mismatches_fail_closed() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let actor = fixture.actor_pubkey; + let binding_id = fixture.binding_id; + let binding_version = fixture.binding_version; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let validator = AuthorizationLeaseValidator::new(clock); + let mut requirement = LeaseUseRequirement { + context_version: AuthContextVersion::V1, + lease_version: LeaseVersion::new(2).expect("synthetic version is valid"), + authorization_domain: domain(), + transport: AuthTransport::RelayWebSocket, + actor_pubkey: actor, + binding_id, + binding_version, + profile_id: lease.profile_id().clone(), + policy_version: lease.policy_version().clone(), + capability: AuthorizationCapability::CommunityRead, + }; + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::LeaseVersionMismatch) + )); + requirement.lease_version = LeaseVersion::INITIAL; + requirement.policy_version = + PolicyVersion::new("changed.synthetic.example").expect("synthetic version is valid"); + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::PolicyVersionMismatch) + )); + requirement.policy_version = lease.policy_version().clone(); + requirement.capability = AuthorizationCapability::CommunityWrite; + assert!(matches!( + validator.authorize(lease, &requirement), + Err(crate::LeaseValidationError::MissingCapability) + )); + } + + #[tokio::test] + async fn verification_only_is_short_lived_and_has_no_access_context() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let status = finalizer + .finalize_verification_only( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + VerificationStatusPolicy::new( + ApplicationLeaseLimit::from_seconds(30) + .expect("synthetic status limit is valid"), + AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"), + ), + ) + .expect("complete current direct evidence produces display status"); + assert_eq!(status.expires_at(), 1_025); + assert!(status + .is_current(clock.as_ref()) + .expect("clock is available")); + } + + #[tokio::test] + async fn renewal_hooks_expose_renew_and_hard_expiry_boundaries() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let fixture = direct_fixture(1_500, 1_400); + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; + let context = finalizer + .finalize_access( + fixture.input, + fixture.policy, + fixture.authorization, + snapshot, + &expected_profile, + fixture.binding_bound, + access_policy(100), + LeaseVersion::INITIAL, + ) + .expect("complete current evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + let schedule = lease.renewal_schedule( + LeaseRenewalLeadTime::from_seconds(20).expect("synthetic lead is valid"), + ); + let validator = AuthorizationLeaseValidator::new(clock.clone()); + assert_eq!(schedule.renew_at(), 1_075); + assert_eq!(schedule.expires_at(), 1_095); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::Current + ); + clock.set(schedule.renew_at()); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::RenewNow + ); + clock.set(schedule.expires_at()); + assert_eq!( + validator + .renewal_action(schedule) + .expect("clock is available"), + LeaseRenewalAction::Expired + ); + } + + #[tokio::test] + async fn delegated_lease_is_bounded_by_delegation_and_owner_binding() { + let clock = Arc::new(FixedClock::new(1_000)); + let finalizer = AuthorizationFinalizer::new(clock.clone()); + let expected_profile = profile(); + let owner = Keys::generate().public_key(); + let delegate = Keys::generate().public_key(); + let principal = crate::FederatedPrincipal::new( + "https://issuer.synthetic.example", + "owner-subject-synthetic", + ) + .expect("synthetic principal is valid"); + let proof = VerifiedNostrProof::new( + domain(), + AuthTransport::RelayWebSocket, + delegate, + AuthMethod::Nip42, + Some( + VerifiedTransportDelegation::new_unrestricted( + owner, + delegate, + Uuid::from_u128(0x401), + 1, + Some( + DelegationExpiry::new(1_050).expect("synthetic delegation expiry is valid"), + ), + ) + .expect("synthetic delegation is valid"), + ), + ) + .expect("synthetic proof is valid"); + let binding_id = Uuid::from_u128(0x400); + let binding_version = BindingVersion::new(9).expect("synthetic version is valid"); + let owner_binding = VersionedBindingRef::new_existing_active_for_test( + domain(), + binding_id, + principal.clone(), + owner, + binding_version, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding is valid"); + let owner_resolution = AuthoritativeBindingResolution::existing_active( + AuthoritativeBindingEvidence::new( + domain(), + binding_id, + principal, + owner, + binding_version, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding resolution is valid"), + ); + let request = AuthorizationRequest::delegated( + &proof, + &owner_resolution, + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), + CapabilitySet::single(AuthorizationCapability::CommunityWrite), + Uuid::from_u128(0x500), + 1_000, + ) + .expect("synthetic delegated request is valid"); + let snapshot = match resolve_authorization( + &AllowProvider { + issued_at: 999, + fresh_until: 1_300, + }, + &request, + clock.as_ref(), + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), + ) + .await + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic provider must allow, got {other:?}"), + }; + assert_eq!(snapshot.effective_until(), 1_050); + let binding_bound = BindingLeaseBound::new(&owner_binding, 1_400) + .expect("synthetic binding bound is valid"); + let admission = snapshot + .verified_owner_admission(&owner_binding) + .expect("delegated snapshot matches the exact owner binding"); + let input = AuthContextInput::new( + buzz_core::tenant::TenantContext::resolved(domain(), "relay.synthetic.example"), + Uuid::from_u128(0x500), + proof, + AuthorizedCommunityAccess::new(domain(), vec![Scope::MessagesWrite], None), + ); + let authorization = FederatedAuthorization::Delegated { + owner: owner_binding, + admission, + }; + let context = finalizer + .finalize_access( + input, + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), + authorization, + snapshot, + &expected_profile, + binding_bound, + access_policy(500), + LeaseVersion::INITIAL, + ) + .expect("delegated evidence finalizes"); + let lease = context.authorization_lease().expect("lease is present"); + assert_eq!(lease.owner_pubkey(), Some(owner)); + assert_eq!(lease.binding_id(), binding_id); + assert_eq!(lease.binding_version(), binding_version); + assert_eq!(lease.expires_at(), 1_045); + } +} 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..59a0128499 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,10 +17,18 @@ /// 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. pub mod error; +/// Trusted-workspace adapter for sealed verifier and binding evidence. +pub mod evidence_adapter; +/// Federated provider-evidence finalization. +pub mod finalization; +/// Bounded, versioned authorization leases. +pub mod lease; /// NIP-42 challenge–response authentication. pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). @@ -41,13 +49,29 @@ pub use context::{ AuthContextVersion, AuthMethod, AuthTransport, AuthorityAdapterError, AuthorityAdapterFuture, AuthorizationReason, AuthorizedCommunityAccess, BindingResolutionRequest, BindingSource, BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, - DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, - ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, - FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, - VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, - VerifiedTransportDelegation, VersionedBindingRef, + DelegatedRelationshipId, DelegatedRelationshipRevision, DelegationCapability, DelegationExpiry, + DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, + FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, + FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, VerifiedFederatedAssertion, + VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOperationBinding, + VerifiedOperationBindingKind, VerifiedOwnerAdmission, VerifiedTransportDelegation, + VersionedBindingRef, }; pub use error::AuthError; +pub use evidence_adapter::{ + ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput, + VerifiedEvidenceAdapter, +}; +pub use finalization::{AuthorizationFinalizer, FinalizationError, VerificationOnlyDisposition}; +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,13 +79,14 @@ pub use nip98_replay::{ MAX_REPLAY_TTL_SECS, }; pub use provider::{ - AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial, + resolve_authorization, AuthorizationAuthority, AuthorizationCapability, + AuthorizationClock as ProviderAuthorizationClock, AuthorizationDenial, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, - CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, - ProviderAuthorizationError, ProviderContractError, ProviderDecision, ProviderTimeout, - ProviderUnavailable, ProviderUnavailableReason, RetryAfter, MAX_PROVIDER_FRESHNESS_SECONDS, - MAX_PROVIDER_TIMEOUT, + CapabilitySnapshot, DecisionSource, OwnerAdmissionError, PolicyVersion, ProviderAllow, + ProviderAllowReason, ProviderAuthorizationError, ProviderContractError, ProviderDecision, + ProviderTimeout, ProviderUnavailable, ProviderUnavailableReason, RetryAfter, + MAX_PROVIDER_FRESHNESS_SECONDS, MAX_PROVIDER_TIMEOUT, }; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 7b3e032962..77fc26dc9f 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -15,10 +15,10 @@ use crate::context::{ authority::{resolve_direct_binding, resolve_existing_binding}, resolve_current_federated_policy, AdmissionExpiry, AssertionTransport, AuthContext, AuthContextError, AuthContextInput, AuthMethod, AuthTransport, AuthoritativeBindingResolution, - AuthoritativeFederatedResolution, AuthorityAdapterError, BindingVersion, + AuthoritativeFederatedResolution, AuthorityAdapterError, AuthorizationReason, BindingVersion, CapabilityFinalizationSeal, FederatedAuthorityAdapter, FederatedPolicyStamp, FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, - VerifiedOwnerAdmission, + VerifiedOwnerAdmission, VersionedBindingRef, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -68,6 +68,11 @@ impl fmt::Debug for AuthorizationCapability { pub struct CapabilitySet(Vec); impl CapabilitySet { + /// Build the exact one-capability request used by on-demand authorization. + pub fn single(capability: AuthorizationCapability) -> Self { + Self(vec![capability]) + } + /// Build a non-empty set, sorting and removing duplicate capabilities. pub fn new( mut capabilities: Vec, @@ -91,6 +96,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 { @@ -124,6 +134,10 @@ impl AuthorizationProfileId { } Ok(Self(value)) } + #[cfg(test)] + pub(crate) fn new(value: impl Into) -> Result { + Self::from_server_configuration(value) + } /// Exact profile identifier for provider routing. pub fn as_str(&self) -> &str { &self.0 @@ -354,6 +368,90 @@ impl AuthorizationRequest { let Some(delegation) = proof.verified_delegation() else { return Err(ProviderContractError::DelegationRequired); }; + if !delegation.capability().is_transport_wide() { + return Err(ProviderContractError::UnsupportedDelegationScope); + } + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(ProviderContractError::DelegatedOwnerMismatch); + } + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::DelegationExpired); + } + if owner + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::BindingExpired); + } + let evidence_valid_from = federated_policy.stamp().effective_from(); + let mut evidence_valid_until = federated_policy.stamp().effective_until(); + if let Some(delegation) = delegation.expires_at() { + evidence_valid_until = evidence_valid_until.min(delegation.unix_seconds()); + } + if let Some(binding) = owner.expires_at() { + evidence_valid_until = evidence_valid_until.min(binding.unix_seconds()); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Delegated { + owner_pubkey: owner.bound_pubkey(), + binding_id: owner.binding_id(), + binding_version: owner.binding_version(), + }, + principal: owner.principal().clone(), + key_attested: false, + assertion_transport: None, + assertion_not_before: None, + assertion_expires_at: None, + federated_policy: federated_policy.into_stamp(), + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DelegatedOwnerBinding, + evidence_valid_from, + evidence_valid_until, + }) + } + + /// Build a delegated request from a sealed active binding-store record. + /// + /// The evidence adapter can create this record only from typed current + /// storage output. Enrolled-in-request bindings are rejected so delegated + /// authorization retains O3's existing-active requirement. + pub fn delegated_from_active_binding( + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; + if proof.authorization_domain() != owner.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if owner.authorization_reason() != AuthorizationReason::ExistingBinding { + return Err(ProviderContractError::DelegatedBindingNotExistingActive); + } + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired); + }; + if !delegation.capability().is_transport_wide() { + return Err(ProviderContractError::UnsupportedDelegationScope); + } if delegation.owner_pubkey() != owner.bound_pubkey() { return Err(ProviderContractError::DelegatedOwnerMismatch); } @@ -1133,7 +1231,6 @@ impl CapabilitySnapshot { pub const fn reason(&self) -> ProviderAllowReason { self.reason } - /// Consume a direct capability decision and finalize authoritative context. /// /// The current enrollment policy is reread after provider I/O, then the @@ -1241,7 +1338,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)?, @@ -1363,6 +1460,38 @@ impl CapabilitySnapshot { } Ok(()) } + + /// Derive current delegated-owner admission for one exact active binding. + pub fn verified_owner_admission( + &self, + owner: &VersionedBindingRef, + ) -> Result { + if self.decision_source != DecisionSource::DelegatedOwnerBinding { + return Err(OwnerAdmissionError::NotDelegatedSnapshot); + } + if self.authorization_domain != owner.authorization_domain() { + return Err(OwnerAdmissionError::AuthorizationDomainMismatch); + } + if self.owner_pubkey != Some(owner.bound_pubkey()) { + return Err(OwnerAdmissionError::OwnerKeyMismatch); + } + if self.principal != *owner.principal() { + return Err(OwnerAdmissionError::PrincipalMismatch); + } + if self.binding_id != Some(owner.binding_id()) { + return Err(OwnerAdmissionError::BindingIdMismatch); + } + if self.binding_version != Some(owner.binding_version()) { + return Err(OwnerAdmissionError::BindingVersionMismatch); + } + let fresh_until = AdmissionExpiry::new(self.fresh_until) + .map_err(|_| OwnerAdmissionError::InvalidFreshnessBound)?; + Ok(VerifiedOwnerAdmission::from_capability_snapshot( + self.authorization_domain, + self.principal.clone(), + fresh_until, + )) + } } fn finalization_time( @@ -1435,6 +1564,40 @@ where } } +/// Failure to derive delegated-owner admission from a capability snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum OwnerAdmissionError { + #[error("capability snapshot does not carry delegated owner authority")] + NotDelegatedSnapshot, + #[error("capability snapshot and owner binding domains do not match")] + AuthorizationDomainMismatch, + #[error("capability snapshot and owner binding keys do not match")] + OwnerKeyMismatch, + #[error("capability snapshot and owner binding principals do not match")] + PrincipalMismatch, + #[error("capability snapshot and owner binding identifiers do not match")] + BindingIdMismatch, + #[error("capability snapshot and owner binding versions do not match")] + BindingVersionMismatch, + #[error("capability snapshot has an invalid admission freshness bound")] + InvalidFreshnessBound, +} + +impl OwnerAdmissionError { + pub const fn code(self) -> &'static str { + match self { + Self::NotDelegatedSnapshot => "authorization_owner_admission_001", + Self::AuthorizationDomainMismatch => "authorization_owner_admission_002", + Self::OwnerKeyMismatch => "authorization_owner_admission_003", + Self::PrincipalMismatch => "authorization_owner_admission_004", + Self::BindingIdMismatch => "authorization_owner_admission_005", + Self::BindingVersionMismatch => "authorization_owner_admission_006", + Self::InvalidFreshnessBound => "authorization_owner_admission_007", + } + } +} + impl fmt::Debug for CapabilitySnapshot { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -1496,7 +1659,7 @@ impl fmt::Debug for AuthorizationOutcome { /// completes, an allowed decision is checked against exactly one fresh sample. /// Provider freshness and all effective evidence bounds use that same value; /// callers must not precompute and pass a decision-start timestamp. -async fn resolve_authorization( +pub async fn resolve_authorization( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, clock: &dyn AuthorizationClock, @@ -1716,6 +1879,9 @@ pub enum ProviderContractError { /// A capability snapshot was presented to a different configured runtime. #[error("provider capability snapshot does not belong to this authorization runtime")] AuthorizationRuntimeMismatch, + /// A narrower operation-bound delegation reached a transport-wide request path. + #[error("delegation scope is not valid for transport-wide authorization")] + UnsupportedDelegationScope, } impl ProviderContractError { @@ -1756,6 +1922,7 @@ impl ProviderContractError { Self::CapabilityBindingChanged => "authorization_provider_contract_032", Self::FederatedPolicyChanged => "authorization_provider_contract_033", Self::AuthorizationRuntimeMismatch => "authorization_provider_contract_034", + Self::UnsupportedDelegationScope => "authorization_provider_contract_035", } } } diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 190cdf7da6..f64273d52b 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -336,15 +336,13 @@ 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, } } -fn all_contract_errors() -> [ProviderContractError; 34] { +fn all_contract_errors() -> [ProviderContractError; 35] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -380,6 +378,7 @@ fn all_contract_errors() -> [ProviderContractError; 34] { ProviderContractError::CapabilityBindingChanged, ProviderContractError::FederatedPolicyChanged, ProviderContractError::AuthorizationRuntimeMismatch, + ProviderContractError::UnsupportedDelegationScope, ] } @@ -468,6 +467,8 @@ fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProo let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x501), + 1, Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), ) .expect("synthetic delegation is valid"); @@ -1837,6 +1838,22 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); + let owner_binding = VersionedBindingRef::new_existing_active_for_test( + domain(1), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding is valid"); + let admission = snapshot + .verified_owner_admission(&owner_binding) + .expect("delegated snapshot matches the exact owner binding"); + assert_eq!(admission.authorization_domain(), domain(1)); + assert_eq!(admission.principal(), request.principal()); + assert_eq!(admission.fresh_until().unix_seconds(), 180); } #[tokio::test] @@ -2201,6 +2218,8 @@ fn request_construction_rejects_mismatched_verified_evidence() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x502), + 1, Some(DelegationExpiry::new(NOW + 20).expect("synthetic expiry is valid")), ) .expect("synthetic delegation is valid"); @@ -2258,6 +2277,8 @@ fn request_construction_rejects_mismatched_verified_evidence() { let expired_delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x503), + 1, Some(DelegationExpiry::new(NOW).expect("synthetic expiry is valid")), ) .expect("synthetic delegation is valid"); diff --git a/crates/buzz-core/src/client_binding_status.rs b/crates/buzz-core/src/client_binding_status.rs new file mode 100644 index 0000000000..6428938d23 --- /dev/null +++ b/crates/buzz-core/src/client_binding_status.rs @@ -0,0 +1,1289 @@ +//! Relay-authenticated client binding status. +//! +//! Kind `24244` is a short-lived, ephemeral envelope whose JSON content names +//! the exact authorization domain and event-author key to which presentation +//! applies. The status is display-only: it is not identity proof, membership, +//! an authorization decision, or an access lease. Consumers must obtain a +//! value through +//! [`validate_client_binding_status_event`](crate::client_binding_status::validate_client_binding_status_event) +//! or [`ClientBindingStatusTracker`](crate::client_binding_status::ClientBindingStatusTracker) +//! rather than mutable profile fields or client-supplied claims. + +use std::fmt; + +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Timestamp}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{kind::KIND_CLIENT_BINDING_STATUS, verify_event, CommunityId}; + +/// Wire version accepted by this module. +pub const CLIENT_BINDING_STATUS_VERSION: u64 = 1; + +/// Maximum lifetime of a client binding status, in seconds. +/// +/// Producers may choose a shorter lifetime. A longer lifetime fails closed. +pub const MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS: u64 = 300; + +/// Explicit client-status clock-skew allowance. +/// +/// Status is presentation-only and issued from centrally injected relay time, +/// so the portable profile permits no future issue-time skew. +pub const CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS: u64 = 0; + +/// Maximum encoded payload length. +pub const MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES: usize = 4096; + +/// Maximum encoded length of the opaque policy revision. +pub const MAX_CLIENT_BINDING_STATUS_POLICY_VERSION_BYTES: usize = 256; + +/// Maximum encoded length of the optional privacy-approved display label. +pub const MAX_CLIENT_BINDING_STATUS_LABEL_BYTES: usize = 80; + +/// Server-selected, display-only status disposition. +/// +/// V1 deliberately exposes only current verification or an opaque withdrawal. +/// Revocation, rotation, lineage, retirement, and other lifecycle causes are +/// durable server-side history and are never part of the client contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientBindingStatusDisposition { + /// The client may display current verification while the envelope is fresh. + DisplayCurrent, + /// Clear current presentation and advance only the scoped replay floor. + Withdrawn, +} + +/// A validated v1 client binding status. +/// +/// Construction proves that one correctly signed event from the expected +/// relay matched the caller's server-resolved authorization domain and exact +/// message-author key at the injected validation time. It does not grant or +/// deny any capability. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingStatusV1 { + event_id: EventId, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: Option, + policy_version: Option, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option, +} + +impl fmt::Debug for ClientBindingStatusV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusV1") + .field("event_id", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("status_revision", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("disposition", &self.disposition) + .field( + "display_label", + &self.display_label.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + +impl ClientBindingStatusV1 { + /// Signed event identifier used for equal-revision idempotency. + pub const fn event_id(&self) -> EventId { + self.event_id + } + + /// Server-resolved authorization domain for which this status is valid. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact event-author key whose messages may consume this status. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Positive version of the identity-to-key binding. + pub const fn binding_version(&self) -> Option { + self.binding_version + } + + /// Opaque provider-neutral policy revision. + pub fn policy_version(&self) -> Option<&str> { + self.policy_version.as_deref() + } + + /// Positive, monotonically increasing revision for this scoped status. + pub const fn status_revision(&self) -> u64 { + self.status_revision + } + + /// Relay issue time as Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Exclusive freshness bound as Unix seconds. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Server-selected, display-only disposition. + pub const fn disposition(&self) -> ClientBindingStatusDisposition { + self.disposition + } + + /// Optional privacy-approved label for current presentation. + pub fn display_label(&self) -> Option<&str> { + self.display_label.as_deref() + } + + /// Returns `true` only for an active, current presentation disposition. + /// + /// Freshness and relay authentication have already been checked by + /// [`validate_client_binding_status_event`]. This method must not be used + /// for access control. + pub const fn displays_current_binding(&self) -> bool { + matches!( + self.disposition, + ClientBindingStatusDisposition::DisplayCurrent + ) + } +} + +/// Validated producer input for one v1 client binding status. +/// +/// This is a serialization/signing input only. It intentionally carries no +/// authorization context, capability set, membership state, or access lease. +pub struct ClientBindingStatusInputV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: Option, + policy_version: Option, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option, +} + +impl fmt::Debug for ClientBindingStatusInputV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusInputV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("status_revision", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("disposition", &self.disposition) + .field( + "display_label", + &self.display_label.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + +impl ClientBindingStatusInputV1 { + /// Construct bounded, provider-neutral current-status input. + #[allow(clippy::too_many_arguments)] + pub fn current( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: u64, + policy_version: impl Into, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + display_label: Option, + ) -> Result { + let value = Self { + authorization_domain, + event_author_pubkey, + binding_version: Some(binding_version), + policy_version: Some(policy_version.into()), + status_revision, + issued_at, + fresh_until, + disposition: ClientBindingStatusDisposition::DisplayCurrent, + display_label, + }; + validate_payload_fields( + value.authorization_domain, + value.binding_version, + value.policy_version.as_deref(), + value.status_revision, + value.issued_at, + value.fresh_until, + value.disposition, + value.display_label.as_deref(), + )?; + Ok(value) + } + + /// Construct an opaque withdrawal carrying no binding or lifecycle data. + pub fn withdrawn( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + ) -> Result { + let value = Self { + authorization_domain, + event_author_pubkey, + binding_version: None, + policy_version: None, + status_revision, + issued_at, + fresh_until, + disposition: ClientBindingStatusDisposition::Withdrawn, + display_label: None, + }; + validate_payload_fields( + value.authorization_domain, + value.binding_version, + value.policy_version.as_deref(), + value.status_revision, + value.issued_at, + value.fresh_until, + value.disposition, + value.display_label.as_deref(), + )?; + Ok(value) + } + + /// Sign this status with the relay key advertised through NIP-11 `self`. + pub fn sign_with_relay_keys( + self, + relay_keys: &Keys, + ) -> Result { + let wire = WireClientBindingStatusV1 { + version: CLIENT_BINDING_STATUS_VERSION, + authorization_domain: self.authorization_domain.as_uuid().to_string(), + event_author_pubkey: self.event_author_pubkey.to_hex(), + status_revision: self.status_revision, + issued_at: self.issued_at, + fresh_until: self.fresh_until, + status: self.disposition, + binding_version: self.binding_version, + policy_version: self.policy_version, + display_label: self.display_label, + }; + let content = serde_json::to_string(&wire) + .map_err(|_| ClientBindingStatusBuildError::Serialization)?; + if content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES { + return Err(ClientBindingStatusBuildError::PayloadTooLarge); + } + EventBuilder::new(Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), content) + .tags([]) + .custom_created_at(Timestamp::from(wire.issued_at)) + .sign_with_keys(relay_keys) + .map_err(|_| ClientBindingStatusBuildError::Signing) + } +} + +#[derive(Deserialize)] +struct VersionHeader { + version: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WireClientBindingStatusV1 { + version: u64, + authorization_domain: String, + event_author_pubkey: String, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + status: ClientBindingStatusDisposition, + #[serde(default, skip_serializing_if = "Option::is_none")] + binding_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + policy_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_label: Option, +} + +/// Validate and authenticate a relay-issued v1 client binding status event. +/// +/// `trusted_relay_pubkey`, `expected_authorization_domain`, and +/// `expected_event_author_pubkey` must come from connection or message context, +/// never from the status payload. `now` is injected Unix time; the exclusive +/// expiry rule means `now == fresh_until` is expired. Future-issued statuses +/// are rejected under [`CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS`]. +pub fn validate_client_binding_status_event( + event: &Event, + trusted_relay_pubkey: &PublicKey, + expected_authorization_domain: CommunityId, + expected_event_author_pubkey: &PublicKey, + now: u64, +) -> Result { + if event.kind.as_u16() as u32 != KIND_CLIENT_BINDING_STATUS { + return Err(ClientBindingStatusError::WrongKind); + } + if event.content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES { + return Err(ClientBindingStatusError::PayloadTooLarge); + } + verify_event(event).map_err(|_| ClientBindingStatusError::UnauthenticatedEvent)?; + if event.pubkey != *trusted_relay_pubkey { + return Err(ClientBindingStatusError::UnexpectedRelay); + } + if !event.tags.is_empty() { + return Err(ClientBindingStatusError::UnexpectedTags); + } + + let header: VersionHeader = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingStatusError::MalformedPayload)?; + if header.version != CLIENT_BINDING_STATUS_VERSION { + return Err(ClientBindingStatusError::UnsupportedVersion); + } + + let wire: WireClientBindingStatusV1 = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingStatusError::MalformedPayload)?; + + let authorization_domain = Uuid::parse_str(&wire.authorization_domain) + .map_err(|_| ClientBindingStatusError::InvalidAuthorizationDomain)?; + if authorization_domain.is_nil() + || authorization_domain.to_string() != wire.authorization_domain + { + return Err(ClientBindingStatusError::InvalidAuthorizationDomain); + } + let authorization_domain = CommunityId::from_uuid(authorization_domain); + if authorization_domain != expected_authorization_domain { + return Err(ClientBindingStatusError::AuthorizationDomainMismatch); + } + + if wire.event_author_pubkey.len() != 64 + || !wire + .event_author_pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ClientBindingStatusError::InvalidEventAuthorPubkey); + } + let event_author_pubkey = PublicKey::from_hex(&wire.event_author_pubkey) + .map_err(|_| ClientBindingStatusError::InvalidEventAuthorPubkey)?; + if event_author_pubkey != *expected_event_author_pubkey { + return Err(ClientBindingStatusError::EventAuthorMismatch); + } + + let disposition = wire.status; + let binding_version = wire.binding_version; + let policy_version = wire.policy_version; + let display_label = wire.display_label; + + validate_payload_fields( + authorization_domain, + binding_version, + policy_version.as_deref(), + wire.status_revision, + wire.issued_at, + wire.fresh_until, + disposition, + display_label.as_deref(), + )?; + if event.created_at.as_secs() != wire.issued_at { + return Err(ClientBindingStatusError::EventTimeMismatch); + } + if wire.issued_at > now.saturating_add(CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS) { + return Err(ClientBindingStatusError::NotYetValid); + } + if now >= wire.fresh_until { + return Err(ClientBindingStatusError::Expired); + } + + Ok(ClientBindingStatusV1 { + event_id: event.id, + authorization_domain, + event_author_pubkey, + binding_version, + policy_version, + status_revision: wire.status_revision, + issued_at: wire.issued_at, + fresh_until: wire.fresh_until, + disposition, + display_label, + }) +} + +#[allow(clippy::too_many_arguments)] +fn validate_payload_fields( + authorization_domain: CommunityId, + binding_version: Option, + policy_version: Option<&str>, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option<&str>, +) -> Result<(), ClientBindingStatusError> { + if authorization_domain.as_uuid().is_nil() { + return Err(ClientBindingStatusError::InvalidAuthorizationDomain); + } + match disposition { + ClientBindingStatusDisposition::DisplayCurrent => { + if binding_version.is_none_or(|version| version == 0) { + return Err(ClientBindingStatusError::InvalidBindingVersion); + } + let Some(policy_version) = policy_version else { + return Err(ClientBindingStatusError::InvalidPolicyVersion); + }; + if policy_version.is_empty() + || policy_version.len() > MAX_CLIENT_BINDING_STATUS_POLICY_VERSION_BYTES + || policy_version.trim() != policy_version + || policy_version.chars().any(char::is_control) + { + return Err(ClientBindingStatusError::InvalidPolicyVersion); + } + } + ClientBindingStatusDisposition::Withdrawn => { + if binding_version.is_some() || policy_version.is_some() || display_label.is_some() { + return Err(ClientBindingStatusError::WithdrawalContainsCurrentState); + } + } + } + if status_revision == 0 { + return Err(ClientBindingStatusError::InvalidStatusRevision); + } + if issued_at == 0 { + return Err(ClientBindingStatusError::InvalidIssueTime); + } + if fresh_until <= issued_at { + return Err(ClientBindingStatusError::InvalidFreshnessBound); + } + if fresh_until - issued_at > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS { + return Err(ClientBindingStatusError::FreshnessWindowTooLong); + } + validate_display_label(disposition, display_label) +} + +fn validate_display_label( + disposition: ClientBindingStatusDisposition, + display_label: Option<&str>, +) -> Result<(), ClientBindingStatusError> { + let Some(label) = display_label else { + return Ok(()); + }; + if disposition != ClientBindingStatusDisposition::DisplayCurrent + || label.is_empty() + || label.len() > MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + || label.trim() != label + || label.chars().any(char::is_control) + { + return Err(ClientBindingStatusError::InvalidDisplayLabel); + } + Ok(()) +} + +/// One accepted high-water update from [`ClientBindingStatusTracker`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientBindingStatusUpdate { + /// A strictly newer revision replaced presentation state. + Accepted, + /// The exact same signed event was observed again. + Duplicate, +} + +#[derive(Clone, Copy)] +struct StatusHighWater { + revision: u64, + event_id: EventId, +} + +/// Client-side, scope-keyed status revision fold. +/// +/// The tracker authenticates every event against one trusted relay/domain/ +/// author tuple. A lower revision or a different event at the same revision is +/// rejected. Expiry and disconnect clear presentation while retaining the +/// high-water mark, so a previously seen envelope cannot restore a badge. +pub struct ClientBindingStatusTracker { + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + high_water: Option, + status: Option, +} + +impl fmt::Debug for ClientBindingStatusTracker { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusTracker") + .field("trusted_relay_pubkey", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("high_water", &self.high_water.map(|_| "[redacted]")) + .field("status", &self.status.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +impl ClientBindingStatusTracker { + /// Start an empty fold for one trusted relay/domain/author scope. + pub const fn new( + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + ) -> Self { + Self { + trusted_relay_pubkey, + authorization_domain, + event_author_pubkey, + high_water: None, + status: None, + } + } + + /// Authenticate and fold one signed status event at injected time `now`. + pub fn accept( + &mut self, + event: &Event, + now: u64, + ) -> Result { + let status = validate_client_binding_status_event( + event, + &self.trusted_relay_pubkey, + self.authorization_domain, + &self.event_author_pubkey, + now, + )?; + if let Some(high_water) = self.high_water { + if status.status_revision < high_water.revision { + return Err(ClientBindingStatusFoldError::LowerRevisionReplay); + } + if status.status_revision == high_water.revision { + if status.event_id != high_water.event_id { + return Err(ClientBindingStatusFoldError::ConflictingEqualRevision); + } + return Ok(ClientBindingStatusUpdate::Duplicate); + } + } + self.high_water = Some(StatusHighWater { + revision: status.status_revision, + event_id: status.event_id, + }); + self.status = status.displays_current_binding().then_some(status); + Ok(ClientBindingStatusUpdate::Accepted) + } + + /// Return the fresh accepted status, clearing expired presentation. + pub fn status(&mut self, now: u64) -> Option<&ClientBindingStatusV1> { + if self + .status + .as_ref() + .is_some_and(|status| now >= status.fresh_until) + { + self.status = None; + } + self.status.as_ref() + } + + /// Return only a fresh status allowed to display current verification. + pub fn current_presentation(&mut self, now: u64) -> Option<&ClientBindingStatusV1> { + self.status(now) + .filter(|status| status.displays_current_binding()) + } + + /// Clear presentation on relay disconnect while retaining replay defense. + pub fn on_disconnect(&mut self) { + self.status = None; + } + + /// Replace the trusted scope and clear both presentation and revision state. + /// + /// Call this on relay-identity, authorization-domain, or event-author + /// changes. Evidence from the old scope is never carried into the new one. + pub fn change_scope( + &mut self, + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + ) { + self.trusted_relay_pubkey = trusted_relay_pubkey; + self.authorization_domain = authorization_domain; + self.event_author_pubkey = event_author_pubkey; + self.high_water = None; + self.status = None; + } + + /// Highest revision accepted for the current scope. + pub const fn high_water_revision(&self) -> Option { + match self.high_water { + Some(value) => Some(value.revision), + None => None, + } + } +} + +/// Fail-closed client binding status validation error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ClientBindingStatusError { + /// The event did not use the dedicated ephemeral status kind. + #[error("client binding status event has the wrong kind")] + WrongKind, + /// The event body exceeded the public bound. + #[error("client binding status payload is too large")] + PayloadTooLarge, + /// The event ID or Schnorr signature was invalid. + #[error("client binding status event is not authenticated")] + UnauthenticatedEvent, + /// The signer did not match the relay key established by the connection. + #[error("client binding status signer is not the trusted relay")] + UnexpectedRelay, + /// Status events must not carry tags or private indexing material. + #[error("client binding status event contains unexpected tags")] + UnexpectedTags, + /// The JSON shape, field set, or enum encoding was malformed. + #[error("client binding status payload is malformed")] + MalformedPayload, + /// The payload used a version this client does not understand. + #[error("client binding status version is unsupported")] + UnsupportedVersion, + /// The authorization domain was nil or not a canonical UUID. + #[error("client binding status authorization domain is invalid")] + InvalidAuthorizationDomain, + /// The payload did not match the server-resolved authorization domain. + #[error("client binding status authorization domain does not match")] + AuthorizationDomainMismatch, + /// The event-author key was not canonical lowercase 64-character hex. + #[error("client binding status event-author key is invalid")] + InvalidEventAuthorPubkey, + /// The payload named a key other than the displayed event's author. + #[error("client binding status event-author key does not match")] + EventAuthorMismatch, + /// The binding version was zero. + #[error("client binding status binding version must be positive")] + InvalidBindingVersion, + /// The opaque policy revision was empty, unsafe, or exceeded its bound. + #[error("client binding status policy version is invalid")] + InvalidPolicyVersion, + /// A generic withdrawal attempted to carry current binding state. + #[error("client binding status withdrawal contains current binding state")] + WithdrawalContainsCurrentState, + /// The status revision was zero. + #[error("client binding status revision must be positive")] + InvalidStatusRevision, + /// The issue time was zero. + #[error("client binding status issue time must be positive")] + InvalidIssueTime, + /// Freshness did not strictly follow issue time. + #[error("client binding status freshness bound is invalid")] + InvalidFreshnessBound, + /// The freshness window exceeded the public short-lived maximum. + #[error("client binding status freshness window is too long")] + FreshnessWindowTooLong, + /// The signed Nostr timestamp did not equal the payload issue time. + #[error("client binding status event time does not match its issue time")] + EventTimeMismatch, + /// The payload was issued after the explicit skew allowance. + #[error("client binding status is not yet valid")] + NotYetValid, + /// The exclusive freshness bound was reached. + #[error("client binding status has expired")] + Expired, + /// The optional display label was unsafe, out of bounds, or attached to a + /// non-current disposition. + #[error("client binding status display label is invalid")] + InvalidDisplayLabel, +} + +/// Status-event serialization/signing failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingStatusBuildError { + /// JSON serialization failed. + #[error("client binding status serialization failed")] + Serialization, + /// The serialized payload exceeded its public bound. + #[error("client binding status payload is too large")] + PayloadTooLarge, + /// Nostr event signing failed. + #[error("client binding status signing failed")] + Signing, +} + +/// Status revision-fold failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingStatusFoldError { + /// Cryptographic or wire validation failed. + #[error(transparent)] + InvalidStatus(#[from] ClientBindingStatusError), + /// A lower status revision attempted to restore older presentation. + #[error("client binding status revision is below the accepted high-water mark")] + LowerRevisionReplay, + /// Another signed event reused an accepted revision. + #[error("client binding status revision conflicts with another event")] + ConflictingEqualRevision, +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::JsonUtil; + use serde_json::{json, Value}; + + const DOMAIN: &str = "00000000-0000-4000-8000-000000000123"; + const ISSUED_AT: u64 = 1_800_000_000; + const FRESH_UNTIL: u64 = ISSUED_AT + 120; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::parse_str(DOMAIN).expect("synthetic domain is valid")) + } + + fn input( + author: PublicKey, + revision: u64, + disposition: ClientBindingStatusDisposition, + ) -> ClientBindingStatusInputV1 { + match disposition { + ClientBindingStatusDisposition::DisplayCurrent => ClientBindingStatusInputV1::current( + domain(), + author, + 7, + "synthetic-policy-v1", + revision, + ISSUED_AT, + FRESH_UNTIL, + Some("Synthetic Example".to_string()), + ), + ClientBindingStatusDisposition::Withdrawn => ClientBindingStatusInputV1::withdrawn( + domain(), + author, + revision, + ISSUED_AT, + FRESH_UNTIL, + ), + } + .expect("synthetic status input is valid") + } + + fn signed_status( + relay: &Keys, + author: PublicKey, + revision: u64, + disposition: ClientBindingStatusDisposition, + ) -> Event { + input(author, revision, disposition) + .sign_with_relay_keys(relay) + .expect("synthetic event signs") + } + + fn validate( + event: &Event, + relay: &Keys, + author: &Keys, + now: u64, + ) -> Result { + validate_client_binding_status_event( + event, + &relay.public_key(), + domain(), + &author.public_key(), + now, + ) + } + + #[test] + fn validates_relay_authenticated_current_status() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + let status = validate(&event, &relay, &author, ISSUED_AT) + .expect("synthetic current status validates"); + + assert_eq!(status.event_id(), event.id); + assert_eq!(status.authorization_domain(), domain()); + assert_eq!(status.event_author_pubkey(), author.public_key()); + assert_eq!(status.binding_version(), Some(7)); + assert_eq!(status.policy_version(), Some("synthetic-policy-v1")); + assert_eq!(status.status_revision(), 11); + assert_eq!(status.issued_at(), ISSUED_AT); + assert_eq!(status.fresh_until(), FRESH_UNTIL); + assert_eq!( + status.disposition(), + ClientBindingStatusDisposition::DisplayCurrent + ); + assert_eq!(status.display_label(), Some("Synthetic Example")); + assert!(status.displays_current_binding()); + assert!(event.tags.is_empty()); + } + + #[test] + fn wire_is_current_or_opaque_withdrawal() { + let relay = Keys::generate(); + let author = Keys::generate(); + let cases = [ + ( + ClientBindingStatusDisposition::DisplayCurrent, + "display_current", + ), + (ClientBindingStatusDisposition::Withdrawn, "withdrawn"), + ]; + for (disposition, expected) in cases { + let event = signed_status(&relay, author.public_key(), 11, disposition); + let content: Value = serde_json::from_str(&event.content).expect("content parses"); + assert_eq!(content["status"], expected); + if disposition == ClientBindingStatusDisposition::Withdrawn { + for forbidden in [ + "binding_version", + "policy_version", + "display_label", + "reason", + ] { + assert!( + content.get(forbidden).is_none(), + "unexpected field {forbidden}" + ); + } + } + } + } + + #[test] + fn withdrawal_removes_current_presentation_without_lifecycle_data() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::Withdrawn, + ); + let status = + validate(&event, &relay, &author, ISSUED_AT).expect("synthetic withdrawal validates"); + assert!(!status.displays_current_binding()); + assert_eq!(status.binding_version(), None); + assert_eq!(status.policy_version(), None); + assert!(status.display_label().is_none()); + } + + #[test] + fn exact_expiry_and_future_issue_fail_closed() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + assert_eq!( + validate(&event, &relay, &author, FRESH_UNTIL), + Err(ClientBindingStatusError::Expired) + ); + assert_eq!( + validate(&event, &relay, &author, ISSUED_AT - 1), + Err(ClientBindingStatusError::NotYetValid) + ); + assert_eq!(CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS, 0); + } + + #[test] + fn rejects_wrong_signer_tampering_kind_scope_and_tags() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + assert_eq!( + validate(&event, &wrong_relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnexpectedRelay) + ); + assert_eq!( + validate(&event, &relay, &other_author, ISSUED_AT), + Err(ClientBindingStatusError::EventAuthorMismatch) + ); + assert_eq!( + validate_client_binding_status_event( + &event, + &relay.public_key(), + CommunityId::from_uuid(Uuid::from_u128(999)), + &author.public_key(), + ISSUED_AT, + ), + Err(ClientBindingStatusError::AuthorizationDomainMismatch) + ); + + let wrong_kind = EventBuilder::new(Kind::TextNote, event.content.clone()) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&wrong_kind, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::WrongKind) + ); + + let tagged = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + event.content.clone(), + ) + .tags([nostr::Tag::parse(["p", &author.public_key().to_hex()]) + .expect("synthetic tag is valid")]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&tagged, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnexpectedTags) + ); + + let mut json: Value = serde_json::from_str(&event.as_json()).expect("event parses"); + json["content"] = Value::String("{}".to_string()); + let tampered = Event::from_json(json.to_string()).expect("tampered event parses"); + assert_eq!( + validate(&tampered, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnauthenticatedEvent) + ); + } + + #[test] + fn unknown_version_fields_and_enums_fail_closed() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut payload: Value = serde_json::from_str(&event.content).expect("content parses"); + + payload["version"] = json!(2); + let unknown_version = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&unknown_version, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnsupportedVersion) + ); + + payload["version"] = json!(1); + payload["synthetic_extension"] = json!(true); + let unknown_field = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&unknown_field, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload) + ); + } + + #[test] + fn historical_status_and_lifecycle_fields_cannot_reappear() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut payload: Value = serde_json::from_str(&event.content).expect("content parses"); + let current_keys = payload + .as_object() + .expect("current payload is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + current_keys, + std::collections::BTreeSet::from([ + "authorization_domain", + "binding_version", + "display_label", + "event_author_pubkey", + "fresh_until", + "issued_at", + "policy_version", + "status", + "status_revision", + "version", + ]) + ); + let withdrawn = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let withdrawn_payload: Value = + serde_json::from_str(&withdrawn.content).expect("withdrawn content parses"); + let withdrawn_keys = withdrawn_payload + .as_object() + .expect("withdrawn payload is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + withdrawn_keys, + std::collections::BTreeSet::from([ + "authorization_domain", + "event_author_pubkey", + "fresh_until", + "issued_at", + "status", + "status_revision", + "version", + ]) + ); + payload["status"] = json!("historical_only"); + payload["reason"] = json!("rotated"); + let historical = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&historical, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload) + ); + + for forbidden in [ + "history", + "lineage", + "predecessor", + "replacement", + "tombstone", + "principal", + "issuer", + "subject", + "retirement_reason", + "corporate_history", + "historical_label", + "employment_history", + ] { + let withdrawal = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let mut payload: Value = + serde_json::from_str(&withdrawal.content).expect("content parses"); + payload[forbidden] = json!("forbidden"); + let injected = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&injected, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload), + "accepted forbidden field {forbidden}" + ); + } + } + + #[test] + fn label_is_privacy_bounded_and_current_only() { + let author = Keys::generate(); + for label in [ + "", + " synthetic.example", + "synthetic.example\n", + &"x".repeat(MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + 1), + ] { + assert!(matches!( + ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 7, + "synthetic-policy-v1", + 11, + ISSUED_AT, + FRESH_UNTIL, + Some(label.to_string()), + ), + Err(ClientBindingStatusError::InvalidDisplayLabel) + )); + } + let withdrawal = ClientBindingStatusInputV1::withdrawn( + domain(), + author.public_key(), + 11, + ISSUED_AT, + FRESH_UNTIL, + ) + .expect("withdrawal needs no label"); + assert!(!format!("{withdrawal:?}").contains("Synthetic Example")); + } + + #[test] + fn revision_fold_rejects_lower_and_conflicting_equal_replays() { + let relay = Keys::generate(); + let author = Keys::generate(); + let current = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let withdrawn = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let conflicting_equal = ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 8, + "synthetic-policy-v2", + 12, + ISSUED_AT, + FRESH_UNTIL, + None, + ) + .expect("conflicting current input") + .sign_with_relay_keys(&relay) + .expect("conflicting current signs"); + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain(), author.public_key()); + + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_some()); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert_eq!( + tracker.accept(&withdrawn, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Err(ClientBindingStatusFoldError::LowerRevisionReplay) + ); + assert_eq!( + tracker.accept(&conflicting_equal, ISSUED_AT), + Err(ClientBindingStatusFoldError::ConflictingEqualRevision) + ); + } + + #[test] + fn expiry_disconnect_and_scope_change_clear_presentation() { + let relay = Keys::generate(); + let other_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let current = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain(), author.public_key()); + tracker + .accept(¤t, ISSUED_AT) + .expect("current status accepted"); + + assert!(tracker.current_presentation(FRESH_UNTIL).is_none()); + assert_eq!(tracker.high_water_revision(), Some(11)); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + + let newer = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::DisplayCurrent, + ); + tracker + .accept(&newer, ISSUED_AT) + .expect("newer status accepted"); + tracker.on_disconnect(); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!(tracker.high_water_revision(), Some(12)); + + tracker.change_scope( + other_relay.public_key(), + CommunityId::from_uuid(Uuid::from_u128(999)), + other_author.public_key(), + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!(tracker.high_water_revision(), None); + } + + #[test] + fn debug_output_and_wire_omit_private_identity_material() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let status = validate(&event, &relay, &author, ISSUED_AT) + .expect("synthetic current status validates"); + + let debug = format!("{status:?}"); + assert!(!debug.contains(DOMAIN)); + assert!(!debug.contains(&author.public_key().to_hex())); + assert!(!debug.contains("synthetic-policy-v1")); + assert!(!debug.contains("Synthetic Example")); + + let payload: Value = serde_json::from_str(&event.content).expect("content parses"); + for forbidden in [ + "iss", + "sub", + "issuer", + "audience", + "email", + "display_name", + "binding_id", + "bearer", + ] { + assert!( + payload.get(forbidden).is_none(), + "unexpected field {forbidden}" + ); + } + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 76943c2abf..495fe84654 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -85,6 +85,12 @@ pub const KIND_AUTH: u32 = 22242; pub const KIND_BLOSSOM_AUTH: u32 = 24242; /// Buzz custom one-time identity binding proof (ephemeral, not stored). pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; +/// Buzz relay-authenticated client binding status (ephemeral, not stored). +/// +/// This provisional allocation carries short-lived, display-only status. It +/// is intentionally absent from relay ingest and storage allowlists until the +/// binding lifecycle and client-presentation joins are complete. +pub const KIND_CLIENT_BINDING_STATUS: u32 = 24244; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; @@ -823,6 +829,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { matches!( kind, KIND_NIP43_MEMBERSHIP_LIST + | KIND_CLIENT_BINDING_STATUS | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_DM_VISIBILITY @@ -904,6 +911,12 @@ mod tests { assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST)); } + #[test] + fn client_binding_status_is_relay_only() { + assert!(is_relay_only_kind(KIND_CLIENT_BINDING_STATUS)); + assert!(is_ephemeral(KIND_CLIENT_BINDING_STATUS)); + } + #[test] fn parameterized_replaceable_range() { assert!(!is_parameterized_replaceable(29999)); diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..6be3e97c40 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Relay-authenticated, display-only client binding status contract. +pub mod client_binding_status; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 941c0fc735..296ab89a68 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -7,7 +7,7 @@ use buzz_core::CommunityId; use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use crate::error::Result; @@ -76,6 +76,36 @@ pub async fn archive( Ok(result.rows_affected() > 0) } +/// Transaction-owned identity archive mutation. +#[allow(clippy::too_many_arguments)] +pub async fn archive_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO archived_identities \ + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(consent_path) + .bind(actor) + .bind(reason) + .bind(replaced_by) + .bind(request_event_id) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Unarchives an identity from `community_id`. /// /// Returns `true` if a row was deleted, `false` if the identity was not archived @@ -91,6 +121,21 @@ pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) - Ok(result.rows_affected() > 0) } +/// Transaction-owned identity unarchive mutation. +pub async fn unarchive_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &str, +) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Returns all identities archived in `community_id`, ordered by archive time ascending. pub async fn list_archived( pool: &PgPool, 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/authorization_invalidation.rs b/crates/buzz-db/src/authorization_invalidation.rs new file mode 100644 index 0000000000..c22766a448 --- /dev/null +++ b/crates/buzz-db/src/authorization_invalidation.rs @@ -0,0 +1,1139 @@ +//! Durable provider-neutral authorization invalidation state. +//! +//! Postgres is the authority. Every committed event receives one strictly +//! increasing generation inside its authorization domain. Redis may advertise +//! that generation, but consumers always reconcile selector floors here. + +use std::collections::BTreeMap; +use std::fmt; + +use buzz_core::CommunityId; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +/// Maximum selectors accepted in one atomic invalidation event. +pub const MAX_INVALIDATION_SELECTORS: usize = 64; + +/// Provider-neutral selector classes understood by the authorization runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AuthorizationSelectorKind { + /// Exact issuer-qualified principal fingerprint. + PrincipalFingerprint, + /// Exact Nostr public key. + NostrKey, + /// Stable binding ID together with an invalid-through version. + Binding, + /// Exact runtime session ID. + Session, + /// Entire authorization domain. + Domain, + /// Exact opaque provider policy version. + PolicyVersion, + /// Exact delegated owner Nostr key. + DelegatedOwner, + /// Exact verified delegated relationship and monotonic revision. + DelegatedRelationship, +} + +impl AuthorizationSelectorKind { + /// Stable storage label. + pub const fn as_str(self) -> &'static str { + match self { + Self::PrincipalFingerprint => "principal_fingerprint", + Self::NostrKey => "nostr_key", + Self::Binding => "binding", + Self::Session => "session", + Self::Domain => "domain", + Self::PolicyVersion => "policy_version", + Self::DelegatedOwner => "delegated_owner", + Self::DelegatedRelationship => "delegated_relationship", + } + } + + fn parse(value: &str) -> Result { + match value { + "principal_fingerprint" => Ok(Self::PrincipalFingerprint), + "nostr_key" => Ok(Self::NostrKey), + "binding" => Ok(Self::Binding), + "session" => Ok(Self::Session), + "domain" => Ok(Self::Domain), + "policy_version" => Ok(Self::PolicyVersion), + "delegated_owner" => Ok(Self::DelegatedOwner), + "delegated_relationship" => Ok(Self::DelegatedRelationship), + _ => Err(DbError::InvalidData( + "authorization invalidation selector kind is invalid".into(), + )), + } + } +} + +/// Exact server-issued runtime session target. +/// +/// A connection UUID is insufficient because UUID reuse would silently target +/// another issuance. The independent non-reuse fence is generated once when +/// the server registers the session and retained for its complete lifetime. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct AuthorizationSessionTarget { + session_id: Uuid, + issuance_fence: Uuid, +} + +impl AuthorizationSessionTarget { + /// Construct one exact session issuance from server-owned identifiers. + pub fn new(session_id: Uuid, issuance_fence: Uuid) -> Result { + if session_id.is_nil() || issuance_fence.is_nil() { + return Err(DbError::InvalidData( + "authorization session target requires non-nil session and issuance IDs".into(), + )); + } + Ok(Self { + session_id, + issuance_fence, + }) + } + + /// Runtime connection identifier. + pub const fn session_id(self) -> Uuid { + self.session_id + } + + /// Server-issued fence that prevents session identity reuse. + pub const fn issuance_fence(self) -> Uuid { + self.issuance_fence + } +} + +impl fmt::Debug for AuthorizationSessionTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationSessionTarget") + .field("session_id", &"[redacted]") + .field("issuance_fence", &"[redacted]") + .finish() + } +} + +/// A typed selector for one authorization dependency. +#[derive(Clone, PartialEq, Eq)] +pub enum AuthorizationSelector { + /// Already-derived issuer-qualified principal fingerprint. + PrincipalFingerprint([u8; 32]), + /// Exact Nostr actor key. + NostrKey([u8; 32]), + /// Stable binding ID and highest invalid version. + Binding { + /// Stable binding identifier. + binding_id: Uuid, + /// All binding versions through this value are invalid. + invalid_through: u64, + }, + /// Exact runtime session issuance. + Session(AuthorizationSessionTarget), + /// Entire authorization domain. + Domain, + /// Exact opaque provider policy version. + PolicyVersion(String), + /// Exact delegated owner key. + DelegatedOwner([u8; 32]), + /// Exact delegated relationship identity and invalid-through revision. + DelegatedRelationship { + /// Verifier-defined relationship identifier. + relationship_id: Uuid, + /// All relationship revisions through this value are invalid. + relationship_revision: u64, + }, +} + +impl fmt::Debug for AuthorizationSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationSelector") + .field("kind", &self.kind()) + .field("value", &"[redacted]") + .finish() + } +} + +impl AuthorizationSelector { + /// Derive a fingerprint from exact validated issuer and subject bytes. + pub fn principal(issuer: &str, subject: &str) -> Result { + if issuer.is_empty() || subject.is_empty() { + return Err(DbError::InvalidData( + "authorization principal must be exact and non-empty".into(), + )); + } + Ok(Self::PrincipalFingerprint(tagged_fingerprint( + b"principal", + &[issuer.as_bytes(), subject.as_bytes()], + ))) + } + + /// Preserve a previously derived principal fingerprint. + pub const fn principal_fingerprint(fingerprint: [u8; 32]) -> Self { + Self::PrincipalFingerprint(fingerprint) + } + + /// Select an exact Nostr actor key. + pub const fn nostr_key(key: [u8; 32]) -> Self { + Self::NostrKey(key) + } + + /// Select a binding and all of its versions through `invalid_through`. + pub fn binding(binding_id: Uuid, invalid_through: u64) -> Result { + if binding_id.is_nil() || invalid_through == 0 { + return Err(DbError::InvalidData( + "authorization binding selector requires a non-nil ID and positive version".into(), + )); + } + Ok(Self::Binding { + binding_id, + invalid_through, + }) + } + + /// Select one exact server-issued runtime session. + pub const fn session(target: AuthorizationSessionTarget) -> Self { + Self::Session(target) + } + + /// Select the entire authorization domain. + pub const fn domain() -> Self { + Self::Domain + } + + /// Select an exact non-empty provider policy version. + pub fn policy_version(version: impl Into) -> Result { + let version = version.into(); + if version.is_empty() { + return Err(DbError::InvalidData( + "authorization policy version must not be empty".into(), + )); + } + Ok(Self::PolicyVersion(version)) + } + + /// Select an exact delegated owner key. + pub const fn delegated_owner(key: [u8; 32]) -> Self { + Self::DelegatedOwner(key) + } + + /// Select one exact verified delegated relationship revision. + pub fn delegated_relationship( + relationship_id: Uuid, + relationship_revision: u64, + ) -> Result { + if relationship_id.is_nil() || relationship_revision == 0 { + return Err(DbError::InvalidData( + "delegated relationship selector requires a non-nil ID and positive revision" + .into(), + )); + } + Ok(Self::DelegatedRelationship { + relationship_id, + relationship_revision, + }) + } + + /// Selector class. + pub const fn kind(&self) -> AuthorizationSelectorKind { + match self { + Self::PrincipalFingerprint(_) => AuthorizationSelectorKind::PrincipalFingerprint, + Self::NostrKey(_) => AuthorizationSelectorKind::NostrKey, + Self::Binding { .. } => AuthorizationSelectorKind::Binding, + Self::Session(_) => AuthorizationSelectorKind::Session, + Self::Domain => AuthorizationSelectorKind::Domain, + Self::PolicyVersion(_) => AuthorizationSelectorKind::PolicyVersion, + Self::DelegatedOwner(_) => AuthorizationSelectorKind::DelegatedOwner, + Self::DelegatedRelationship { .. } => AuthorizationSelectorKind::DelegatedRelationship, + } + } + + /// Redaction-safe stable selector fingerprint. + pub fn fingerprint(&self) -> [u8; 32] { + match self { + Self::PrincipalFingerprint(value) => *value, + Self::NostrKey(key) => tagged_fingerprint(b"nostr-key", &[key]), + Self::Binding { binding_id, .. } => { + tagged_fingerprint(b"binding", &[binding_id.as_bytes()]) + } + Self::Session(target) => tagged_fingerprint( + b"session-issuance-v2", + &[ + target.session_id().as_bytes(), + target.issuance_fence().as_bytes(), + ], + ), + Self::Domain => tagged_fingerprint(b"domain", &[]), + Self::PolicyVersion(version) => { + tagged_fingerprint(b"policy-version", &[version.as_bytes()]) + } + Self::DelegatedOwner(key) => tagged_fingerprint(b"delegated-owner", &[key]), + Self::DelegatedRelationship { + relationship_id, + relationship_revision, + } => tagged_fingerprint( + b"delegated-relationship-v1", + &[ + relationship_id.as_bytes(), + &relationship_revision.to_be_bytes(), + ], + ), + } + } + + /// Invalid-through binding version, when this is a binding selector. + pub const fn binding_version_floor(&self) -> Option { + match self { + Self::Binding { + invalid_through, .. + } => Some(*invalid_through), + _ => None, + } + } +} + +/// Effect retained for a selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthorizationInvalidationEffect { + /// Fence evaluations captured before this event's generation. + Fence, + /// Deny this selector until an explicit future recovery mechanism changes authority. + StickyDeny, + /// Permanently deny binding versions through the selector's version floor. + BindingVersionFloor, +} + +impl AuthorizationInvalidationEffect { + const fn is_sticky(self) -> bool { + matches!(self, Self::StickyDeny) + } +} + +/// One selector and effect in an invalidation event. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationEntry { + selector: AuthorizationSelector, + effect: AuthorizationInvalidationEffect, +} + +impl AuthorizationInvalidationEntry { + /// Permanently deny one exact selector until a separately designed and + /// authorized recovery mechanism changes authority. + pub fn sticky_deny(selector: AuthorizationSelector) -> Result { + if selector.kind() == AuthorizationSelectorKind::Binding { + return Err(DbError::InvalidData( + "binding invalidation requires a version-floor entry".into(), + )); + } + Ok(Self { + selector, + effect: AuthorizationInvalidationEffect::StickyDeny, + }) + } + + /// Fence all evaluations already in flight in one domain while allowing + /// later evaluations to resolve fresh policy and binding state. + pub const fn domain_fence() -> Self { + Self { + selector: AuthorizationSelector::Domain, + effect: AuthorizationInvalidationEffect::Fence, + } + } + + /// Fence authority captured before reversible admission loss for one exact + /// principal, Nostr key, delegated owner, or exact delegated relationship. + /// + /// This is intentionally unavailable for bindings, sessions, domains, and + /// policy versions. Binding invalidation uses a monotonic version floor, + /// while domain fencing remains an explicit separate operation. + pub fn admission_loss_fence(selector: AuthorizationSelector) -> Result { + if !matches!( + selector.kind(), + AuthorizationSelectorKind::PrincipalFingerprint + | AuthorizationSelectorKind::NostrKey + | AuthorizationSelectorKind::DelegatedOwner + | AuthorizationSelectorKind::DelegatedRelationship + ) { + return Err(DbError::InvalidData( + "authorization admission loss requires a principal, key, owner, or relationship" + .into(), + )); + } + Ok(Self { + selector, + effect: AuthorizationInvalidationEffect::Fence, + }) + } + + /// Permanently deny one binding ID through a positive version, while + /// permitting a later version of that same stable binding ID. + pub fn binding_version_floor(binding_id: Uuid, invalid_through: u64) -> Result { + Ok(Self { + selector: AuthorizationSelector::binding(binding_id, invalid_through)?, + effect: AuthorizationInvalidationEffect::BindingVersionFloor, + }) + } + + /// Selected dependency. + pub const fn selector(&self) -> &AuthorizationSelector { + &self.selector + } + + /// Retained effect. + pub const fn effect(&self) -> AuthorizationInvalidationEffect { + self.effect + } +} + +impl fmt::Debug for AuthorizationInvalidationEntry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationEntry") + .field("selector", &self.selector) + .field("effect", &self.effect) + .finish() + } +} + +/// Atomic idempotent invalidation request. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationRequest { + event_id: Uuid, + entries: Vec, +} + +impl AuthorizationInvalidationRequest { + /// Validate a non-empty, bounded event. + pub fn new(event_id: Uuid, entries: Vec) -> Result { + if event_id.is_nil() { + return Err(DbError::InvalidData( + "authorization invalidation event ID must not be nil".into(), + )); + } + if entries.is_empty() || entries.len() > MAX_INVALIDATION_SELECTORS { + return Err(DbError::InvalidData( + "authorization invalidation selector count is out of bounds".into(), + )); + } + if entries.iter().any(|entry| { + let kind = entry.selector.kind(); + !match (kind, entry.effect) { + ( + AuthorizationSelectorKind::PrincipalFingerprint + | AuthorizationSelectorKind::NostrKey + | AuthorizationSelectorKind::DelegatedOwner + | AuthorizationSelectorKind::DelegatedRelationship + | AuthorizationSelectorKind::Domain, + AuthorizationInvalidationEffect::Fence, + ) + | ( + AuthorizationSelectorKind::Binding, + AuthorizationInvalidationEffect::BindingVersionFloor, + ) => true, + (_, AuthorizationInvalidationEffect::StickyDeny) => { + kind != AuthorizationSelectorKind::Binding + } + _ => false, + } + }) { + return Err(DbError::InvalidData( + "authorization invalidation selector and effect are incompatible".into(), + )); + } + Ok(Self { event_id, entries }) + } + + /// Idempotency identifier. + pub const fn event_id(&self) -> Uuid { + self.event_id + } + + /// Requested selectors and effects. + pub fn entries(&self) -> &[AuthorizationInvalidationEntry] { + &self.entries + } +} + +impl fmt::Debug for AuthorizationInvalidationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationRequest") + .field("event_id", &"[redacted]") + .field("selector_count", &self.entries.len()) + .finish() + } +} + +/// Redaction-safe receipt for a committed invalidation event. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationInvalidationReceipt { + /// Domain in which the event committed. + pub community_id: CommunityId, + /// Idempotency identifier. + pub event_id: Uuid, + /// Strictly increasing durable generation. + pub generation: u64, +} + +impl fmt::Debug for AuthorizationInvalidationReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationReceipt") + .field("community_id", &"[redacted]") + .field("event_id", &"[redacted]") + .field("generation", &self.generation) + .finish() + } +} + +/// Whether a request committed now or replayed its identical receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthorizationInvalidationResult { + /// This transaction committed the event. + Applied(AuthorizationInvalidationReceipt), + /// An identical request had already committed. + AlreadyApplied(AuthorizationInvalidationReceipt), +} + +impl AuthorizationInvalidationResult { + /// Whether this call committed a new durable invalidation. + pub const fn committed_now(&self) -> bool { + matches!(self, Self::Applied(_)) + } + + /// Durable receipt in either outcome. + pub const fn receipt(self) -> AuthorizationInvalidationReceipt { + match self { + Self::Applied(receipt) | Self::AlreadyApplied(receipt) => receipt, + } + } +} + +/// One durable selector floor returned by reconciliation. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationInvalidationFloor { + /// Selector class. + pub kind: AuthorizationSelectorKind, + /// Redaction-safe stable selector fingerprint. + pub fingerprint: [u8; 32], + /// Most recent generation that touched the floor. + pub generation: u64, + /// Whether the selector remains denied independently of capture generation. + pub sticky_deny: bool, + /// Highest invalid binding version, for binding selectors only. + pub binding_version_floor: Option, +} + +impl fmt::Debug for AuthorizationInvalidationFloor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationFloor") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .field("generation", &self.generation) + .field("sticky_deny", &self.sticky_deny) + .field("binding_version_floor", &self.binding_version_floor) + .finish() + } +} + +/// Consistent durable generation and selector-floor view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorizationInvalidationSnapshot { + /// Domain read from the writer database. + pub community_id: CommunityId, + /// Durable generation at this snapshot. + pub generation: u64, + /// Full floors, or floors changed after the requested delta generation. + pub floors: Vec, +} + +#[derive(Clone)] +struct NormalizedEntry { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + sticky_deny: bool, + binding_version_floor: Option, +} + +fn tagged_fingerprint(tag: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((tag.len() as u64).to_be_bytes()); + digest.update(tag); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().into() +} + +fn normalized_entries(request: &AuthorizationInvalidationRequest) -> Vec { + let mut entries = BTreeMap::new(); + for entry in request.entries() { + let selector = entry.selector(); + let key = (selector.kind(), selector.fingerprint()); + let value = entries.entry(key).or_insert(NormalizedEntry { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + sticky_deny: false, + binding_version_floor: None, + }); + value.sticky_deny |= entry.effect().is_sticky(); + value.binding_version_floor = match ( + value.binding_version_floor, + selector.binding_version_floor(), + ) { + (Some(current), Some(candidate)) => Some(current.max(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + } + entries.into_values().collect() +} + +fn request_fingerprint( + community_id: CommunityId, + event_id: Uuid, + entries: &[NormalizedEntry], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-authorization-invalidation-v1"); + digest.update(community_id.as_uuid().as_bytes()); + digest.update(event_id.as_bytes()); + for entry in entries { + digest.update((entry.kind.as_str().len() as u64).to_be_bytes()); + digest.update(entry.kind.as_str().as_bytes()); + digest.update(entry.fingerprint); + digest.update([u8::from(entry.sticky_deny)]); + digest.update( + entry + .binding_version_floor + .unwrap_or_default() + .to_be_bytes(), + ); + } + digest.finalize().into() +} + +/// Deterministic fingerprint shared by invalidation and restore receipts. +pub fn authorization_invalidation_request_fingerprint( + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, +) -> [u8; 32] { + request_fingerprint( + community_id, + request.event_id(), + &normalized_entries(request), + ) +} + +fn positive_u64(value: i64, label: &str) -> Result { + u64::try_from(value).map_err(|_| { + DbError::InvalidData(format!( + "authorization invalidation {label} is outside the supported range" + )) + }) +} + +fn fingerprint_array(value: Vec) -> Result<[u8; 32]> { + value.try_into().map_err(|_| { + DbError::InvalidData("authorization invalidation fingerprint has invalid length".into()) + }) +} + +impl Db { + /// Atomically allocate a generation and retain the strongest selector floors. + pub async fn apply_authorization_invalidation( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + let entries = normalized_entries(request); + let fingerprint = request_fingerprint(community_id, request.event_id(), &entries); + let mut tx = self.pool.begin().await?; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .execute(&mut *tx) + .await?; + + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .fetch_one(&mut *tx) + .await?; + + if let Some(row) = sqlx::query( + "SELECT generation, request_fingerprint \ + FROM authorization_invalidation_receipts \ + WHERE community_id = $1 AND event_id = $2", + ) + .bind(community_id.as_uuid()) + .bind(request.event_id()) + .fetch_optional(&mut *tx) + .await? + { + let stored: Vec = row.try_get("request_fingerprint")?; + if stored.as_slice() != fingerprint { + return Err(DbError::InvalidData( + "authorization invalidation event ID was reused with different input".into(), + )); + } + let receipt = AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: positive_u64(row.try_get("generation")?, "generation")?, + }; + tx.commit().await?; + return Ok(AuthorizationInvalidationResult::AlreadyApplied(receipt)); + } + + let next_generation = generation.checked_add(1).ok_or_else(|| { + DbError::InvalidData("authorization invalidation generation exhausted".into()) + })?; + sqlx::query( + "INSERT INTO authorization_invalidation_receipts \ + (community_id, event_id, generation, request_fingerprint) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(community_id.as_uuid()) + .bind(request.event_id()) + .bind(next_generation) + .bind(fingerprint.as_slice()) + .execute(&mut *tx) + .await?; + + for entry in entries { + let binding_floor = entry + .binding_version_floor + .map(i64::try_from) + .transpose() + .map_err(|_| { + DbError::InvalidData( + "authorization binding version exceeds database range".into(), + ) + })?; + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, generation, \ + sticky_deny, binding_version_floor) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (community_id, selector_kind, selector_fingerprint) DO UPDATE SET \ + generation = EXCLUDED.generation, \ + sticky_deny = authorization_invalidation_floors.sticky_deny \ + OR EXCLUDED.sticky_deny, \ + binding_version_floor = CASE \ + WHEN authorization_invalidation_floors.binding_version_floor IS NULL \ + THEN EXCLUDED.binding_version_floor \ + WHEN EXCLUDED.binding_version_floor IS NULL \ + THEN authorization_invalidation_floors.binding_version_floor \ + ELSE GREATEST(authorization_invalidation_floors.binding_version_floor, \ + EXCLUDED.binding_version_floor) \ + END, \ + updated_at = NOW()", + ) + .bind(community_id.as_uuid()) + .bind(entry.kind.as_str()) + .bind(entry.fingerprint.as_slice()) + .bind(next_generation) + .bind(entry.sticky_deny) + .bind(binding_floor) + .execute(&mut *tx) + .await?; + } + + sqlx::query( + "UPDATE authorization_invalidation_domains \ + SET generation = $2, updated_at = NOW() WHERE community_id = $1", + ) + .bind(community_id.as_uuid()) + .bind(next_generation) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_payload, lease_expires_at) \ + VALUES ($1, $2, 'authorization.invalidation', $3, $4, \ + clock_timestamp() + INTERVAL '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(request.event_id()) + .bind(fingerprint.as_slice()) + .bind(next_generation.to_be_bytes().as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + Ok(AuthorizationInvalidationResult::Applied( + AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: positive_u64(next_generation, "generation")?, + }, + )) + } + + /// Read a consistent full snapshot from the writer database. + pub async fn authorization_invalidation_snapshot( + &self, + community_id: CommunityId, + ) -> Result { + self.authorization_invalidation_read(community_id, None) + .await + } + + /// Read floors changed after `after_generation` plus the current generation. + pub async fn authorization_invalidation_delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + self.authorization_invalidation_read(community_id, Some(after_generation)) + .await + } + + async fn authorization_invalidation_read( + &self, + community_id: CommunityId, + after_generation: Option, + ) -> Result { + let after_generation = after_generation + .map(i64::try_from) + .transpose() + .map_err(|_| { + DbError::InvalidData( + "authorization invalidation generation exceeds database range".into(), + ) + })?; + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id = $1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *tx) + .await?; + let generation = generation.unwrap_or_default(); + let rows = sqlx::query( + "SELECT selector_kind, selector_fingerprint, generation, sticky_deny, \ + binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND generation <= $2 \ + AND ($3::BIGINT IS NULL OR generation > $3) \ + ORDER BY generation, selector_kind, selector_fingerprint", + ) + .bind(community_id.as_uuid()) + .bind(generation) + .bind(after_generation) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + + let floors = rows + .into_iter() + .map(|row| { + let binding_version: Option = row.try_get("binding_version_floor")?; + Ok(AuthorizationInvalidationFloor { + kind: AuthorizationSelectorKind::parse(row.try_get("selector_kind")?)?, + fingerprint: fingerprint_array(row.try_get("selector_fingerprint")?)?, + generation: positive_u64(row.try_get("generation")?, "floor generation")?, + sticky_deny: row.try_get("sticky_deny")?, + binding_version_floor: binding_version + .map(|value| positive_u64(value, "binding version")) + .transpose()?, + }) + }) + .collect::>>()?; + + Ok(AuthorizationInvalidationSnapshot { + community_id, + generation: positive_u64(generation, "generation")?, + floors, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DbConfig; + + fn request( + event_id: Uuid, + selector: AuthorizationSelector, + ) -> AuthorizationInvalidationRequest { + let entry = match selector { + AuthorizationSelector::Binding { + binding_id, + invalid_through, + } => AuthorizationInvalidationEntry::binding_version_floor(binding_id, invalid_through), + selector => AuthorizationInvalidationEntry::sticky_deny(selector), + } + .expect("test invalidation entry is valid"); + AuthorizationInvalidationRequest::new(event_id, vec![entry]) + .expect("test invalidation request is valid") + } + + #[test] + fn principal_fingerprints_are_exact_and_domain_neutral() { + let a = AuthorizationSelector::principal("issuer-a", "subject").expect("valid principal"); + let b = AuthorizationSelector::principal("issuer-b", "subject").expect("valid principal"); + assert_ne!(a.fingerprint(), b.fingerprint()); + assert_eq!( + a.fingerprint(), + AuthorizationSelector::principal("issuer-a", "subject") + .expect("valid principal") + .fingerprint() + ); + assert!(!format!("{a:?}").contains("issuer-a")); + } + + #[test] + fn duplicate_binding_entries_retain_strongest_version_floor() { + let binding_id = Uuid::new_v4(); + let request = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![ + AuthorizationInvalidationEntry::binding_version_floor(binding_id, 2) + .expect("valid binding floor"), + AuthorizationInvalidationEntry::binding_version_floor(binding_id, 5) + .expect("valid binding floor"), + ], + ) + .expect("valid request"); + let normalized = normalized_entries(&request); + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].binding_version_floor, Some(5)); + assert!(!normalized[0].sticky_deny); + } + + #[test] + fn transient_admission_fences_are_exact_and_never_bindings() { + let allowed = [ + AuthorizationSelector::principal("issuer", "subject").expect("valid principal"), + AuthorizationSelector::nostr_key([1_u8; 32]), + AuthorizationSelector::delegated_owner([2_u8; 32]), + ]; + let entries = allowed + .into_iter() + .map(AuthorizationInvalidationEntry::admission_loss_fence) + .collect::>>() + .expect("exact admission selectors are valid"); + let request = AuthorizationInvalidationRequest::new(Uuid::new_v4(), entries) + .expect("admission-loss request is valid"); + assert!(normalized_entries(&request) + .iter() + .all(|entry| !entry.sticky_deny && entry.binding_version_floor.is_none())); + + for selector in [ + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version("policy").expect("valid policy"), + ] { + assert!(AuthorizationInvalidationEntry::admission_loss_fence(selector).is_err()); + } + let forged_binding_fence = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![AuthorizationInvalidationEntry { + selector: AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + effect: AuthorizationInvalidationEffect::Fence, + }], + ); + assert!(matches!(forged_binding_fence, Err(DbError::InvalidData(_)))); + assert!(AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![AuthorizationInvalidationEntry::domain_fence()], + ) + .is_ok()); + assert!(AuthorizationInvalidationEntry::sticky_deny( + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding") + ) + .is_err()); + } + + #[test] + fn request_debug_redacts_identifiers() { + let event_id = Uuid::new_v4(); + let request = request( + event_id, + AuthorizationSelector::policy_version("private-policy").expect("valid policy"), + ); + let debug = format!("{request:?}"); + assert!(!debug.contains(&event_id.to_string())); + assert!(!debug.contains("private-policy")); + } + + async fn integration_db() -> Db { + let mut config = DbConfig::default(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or(config.database_url); + config.min_connections = 0; + let db = Db::new(&config) + .await + .expect("connect integration database"); + db.migrate().await.expect("run integration migrations"); + db + } + + async fn integration_community(db: &Db) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!( + "authorization-invalidation-{}.example", + id.simple() + )) + .execute(&db.pool) + .await + .expect("insert integration community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn durable_idempotency_concurrency_and_delta_converge() { + let db = integration_db().await; + let community_id = integration_community(&db).await; + let binding_id = Uuid::new_v4(); + let first_id = Uuid::new_v4(); + let first = request( + first_id, + AuthorizationSelector::binding(binding_id, 1).expect("valid binding"), + ); + let first_receipt = db + .apply_authorization_invalidation(community_id, &first) + .await + .expect("first event commits"); + assert_eq!(first_receipt.receipt().generation, 1); + assert!(matches!( + db.apply_authorization_invalidation(community_id, &first) + .await + .expect("identical retry resolves"), + AuthorizationInvalidationResult::AlreadyApplied(_) + )); + + let conflicting_retry = request( + first_id, + AuthorizationSelector::policy_version("different").expect("valid policy"), + ); + assert!(matches!( + db.apply_authorization_invalidation(community_id, &conflicting_retry) + .await, + Err(DbError::InvalidData(_)) + )); + + let second = request( + Uuid::new_v4(), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), + ); + let third = request( + Uuid::new_v4(), + AuthorizationSelector::policy_version("old-policy").expect("valid policy"), + ); + let db_a = db.clone(); + let db_b = db.clone(); + let (second_result, third_result) = tokio::join!( + db_a.apply_authorization_invalidation(community_id, &second), + db_b.apply_authorization_invalidation(community_id, &third), + ); + let mut generations = [ + second_result + .expect("second event commits") + .receipt() + .generation, + third_result + .expect("third event commits") + .receipt() + .generation, + ]; + generations.sort_unstable(); + assert_eq!(generations, [2, 3]); + + let binding_advance = request( + Uuid::new_v4(), + AuthorizationSelector::binding(binding_id, 5).expect("valid binding"), + ); + assert_eq!( + db.apply_authorization_invalidation(community_id, &binding_advance) + .await + .expect("binding floor advances") + .receipt() + .generation, + 4 + ); + let snapshot = db + .authorization_invalidation_snapshot(community_id) + .await + .expect("full snapshot reads"); + assert_eq!(snapshot.generation, 4); + let binding_floor = snapshot + .floors + .iter() + .find(|floor| floor.kind == AuthorizationSelectorKind::Binding) + .expect("binding floor present"); + assert_eq!(binding_floor.binding_version_floor, Some(5)); + assert!(!binding_floor.sticky_deny); + + let delta = db + .authorization_invalidation_delta(community_id, 3) + .await + .expect("delta reads"); + assert_eq!(delta.generation, 4); + assert_eq!(delta.floors.len(), 1); + assert_eq!(delta.floors[0].kind, AuthorizationSelectorKind::Binding); + + let principal = AuthorizationSelector::principal("admission-issuer", "admission-subject") + .expect("valid principal"); + let admission_loss = AuthorizationInvalidationRequest::new( + Uuid::new_v4(), + vec![ + AuthorizationInvalidationEntry::admission_loss_fence(principal.clone()) + .expect("valid admission-loss fence"), + ], + ) + .expect("valid admission-loss request"); + assert_eq!( + db.apply_authorization_invalidation(community_id, &admission_loss) + .await + .expect("admission-loss fence commits") + .receipt() + .generation, + 5 + ); + let delta = db + .authorization_invalidation_delta(community_id, 4) + .await + .expect("admission-loss delta reads"); + assert_eq!(delta.generation, 5); + assert_eq!(delta.floors.len(), 1); + assert_eq!( + delta.floors[0].kind, + AuthorizationSelectorKind::PrincipalFingerprint + ); + assert_eq!(delta.floors[0].fingerprint, principal.fingerprint()); + assert!(!delta.floors[0].sticky_deny); + assert_eq!(delta.floors[0].binding_version_floor, None); + // Activated domains are intentionally one-way in V1. This isolated + // integration database is discarded by the test harness rather than + // bypassing the production marker guard for cleanup. + } +} diff --git a/crates/buzz-db/src/authorization_version.rs b/crates/buzz-db/src/authorization_version.rs new file mode 100644 index 0000000000..5899f51f18 --- /dev/null +++ b/crates/buzz-db/src/authorization_version.rs @@ -0,0 +1,470 @@ +//! Restore-independent monotonic version snapshot for protected authority. + +use std::collections::BTreeMap; + +use buzz_core::CommunityId; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{Db, Result}; + +/// Hashed per-resource high-water marks safe for an external checkpoint. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AuthorizationVersionVector { + /// Issuer-qualified binding selector fingerprint to maximum version. + pub bindings: BTreeMap, + /// Git repository selector fingerprint to publication version. + pub git_publications: BTreeMap, + /// Media-object selector fingerprint to publication version. + pub media_publications: BTreeMap, + /// Protected object surface to cutover generation. + pub object_authority: BTreeMap, + /// Durable invalidation generation. + pub invalidation_generation: u64, + /// Complete PostgreSQL authority epoch, including tombstones and membership. + pub authority_epoch: u64, + /// Durable current-only client-status revision floor. + pub status_revision: u64, +} + +impl AuthorizationVersionVector { + /// True when every recorded floor exists at an equal or higher version. + pub fn dominates(&self, floor: &Self) -> bool { + dominates_map(&self.bindings, &floor.bindings) + && dominates_map(&self.git_publications, &floor.git_publications) + && dominates_map(&self.media_publications, &floor.media_publications) + && dominates_map(&self.object_authority, &floor.object_authority) + && self.invalidation_generation >= floor.invalidation_generation + && self.authority_epoch >= floor.authority_epoch + && self.status_revision >= floor.status_revision + } +} + +fn dominates_map(current: &BTreeMap, floor: &BTreeMap) -> bool { + floor + .iter() + .all(|(selector, version)| current.get(selector).is_some_and(|value| value >= version)) +} + +fn selector_fingerprint(namespace: &[u8], parts: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-authorization-version-selector-v1"); + digest.update((namespace.len() as u64).to_be_bytes()); + digest.update(namespace); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + hex::encode(digest.finalize()) +} + +impl Db { + /// Exact domains that have crossed the one-way Enforce activation boundary. + pub async fn activated_authorization_domains(&self) -> Result> { + let rows: Vec = sqlx::query_scalar( + "SELECT community_id FROM authorization_invalidation_domains ORDER BY community_id", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(CommunityId::from_uuid).collect()) + } + + /// Idempotently activate one Enforce invalidation domain and commit the + /// exact receipt in the same transaction. + /// + /// Production construction witnesses this mutation independently before + /// making the protected transport reachable. Observational modes never + /// call this API. + pub async fn activate_authorization_domain( + &self, + community_id: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + ) -> Result<()> { + const OPERATION_KIND: &str = "authorization_domain_activate_v1"; + let mut tx = self.pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(operation_id.to_string()) + .execute(&mut *tx) + .await?; + if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut *tx) + .await? + { + let kind: String = row.try_get("operation_kind")?; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + if kind != OPERATION_KIND || fingerprint.as_slice() != request_fingerprint { + return Err(crate::DbError::InvalidData( + "protected-domain activation retry conflicts with its durable receipt" + .to_owned(), + )); + } + tx.commit().await?; + return Ok(()); + } + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1) \ + ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1,$2,$3,$4,1,$5,clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(OPERATION_KIND) + .bind(request_fingerprint.as_slice()) + .bind([1_u8].as_slice()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + /// Whether a transaction-owned protected operation committed. + pub async fn has_authorization_operation_receipt( + &self, + community_id: CommunityId, + operation_id: uuid::Uuid, + ) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2)", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_one(&self.pool) + .await?; + Ok(exists) + } + + /// Return the exact request fingerprint for a committed protected operation. + pub async fn authorization_operation_receipt_fingerprint( + &self, + community_id: CommunityId, + operation_id: uuid::Uuid, + ) -> 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() + } + + /// Read the complete protected-authority high-water vector from the writer. + pub async fn authorization_version_vector( + &self, + community_id: CommunityId, + ) -> Result { + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let vector = authorization_version_vector_tx(&mut tx, community_id).await?; + tx.commit().await?; + Ok(vector) + } + + /// Read an exact operation receipt and the complete authority vector from + /// one writer-consistent snapshot. Pending restore recovery must not join + /// a receipt from one database state to a vector from another. + pub async fn authorization_receipt_and_version_vector( + &self, + community_id: CommunityId, + operation_id: Uuid, + ) -> Result<(Option<[u8; 32]>, AuthorizationVersionVector)> { + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await?; + let fingerprint: 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?; + let fingerprint = fingerprint + .map(|bytes| { + bytes.try_into().map_err(|_| { + crate::DbError::InvalidData( + "authorization receipt fingerprint must be 32 bytes".to_owned(), + ) + }) + }) + .transpose()?; + let vector = authorization_version_vector_tx(&mut tx, community_id).await?; + tx.commit().await?; + Ok((fingerprint, vector)) + } +} + +async fn authorization_version_vector_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, +) -> Result { + let mut vector = AuthorizationVersionVector::default(); + for row in sqlx::query( + "SELECT issuer, uid AS subject, max(binding_version) AS version \ + FROM identity_bindings WHERE community_id=$1 \ + GROUP BY issuer, uid", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let issuer: String = row.try_get("issuer")?; + let subject: String = row.try_get("subject")?; + let version: i64 = row.try_get("version")?; + if let Ok(version) = u64::try_from(version) { + vector.bindings.insert( + selector_fingerprint(b"binding", &[issuer.as_bytes(), subject.as_bytes()]), + version, + ); + } + } + for row in sqlx::query( + "SELECT repo_id, publication_version FROM git_repo_publications \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let repo_id: String = row.try_get("repo_id")?; + let version: i64 = row.try_get("publication_version")?; + if let Ok(version) = u64::try_from(version) { + vector + .git_publications + .insert(selector_fingerprint(b"git", &[repo_id.as_bytes()]), version); + } + } + for row in sqlx::query( + "SELECT sha256, publication_version FROM media_publications \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let digest: String = row.try_get("sha256")?; + let version: i64 = row.try_get("publication_version")?; + if let Ok(version) = u64::try_from(version) { + vector.media_publications.insert( + selector_fingerprint(b"media", &[digest.as_bytes()]), + version, + ); + } + } + for row in sqlx::query( + "SELECT surface, generation FROM protected_object_authority \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut **tx) + .await? + { + let surface: String = row.try_get("surface")?; + let generation: i64 = row.try_get("generation")?; + if let Ok(generation) = u64::try_from(generation) { + vector.object_authority.insert(surface, generation); + } + } + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + vector.invalidation_generation = generation + .and_then(|value| u64::try_from(value).ok()) + .unwrap_or_default(); + let epoch: Option<(i64, i64)> = sqlx::query_as( + "SELECT authority_epoch, status_revision \ + FROM authorization_authority_epochs WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + if let Some((authority_epoch, status_revision)) = epoch { + vector.authority_epoch = u64::try_from(authority_epoch).unwrap_or_default(); + vector.status_revision = u64::try_from(status_revision).unwrap_or_default(); + } + Ok(vector) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_or_lower_component_never_dominates() { + let mut floor = AuthorizationVersionVector::default(); + floor.bindings.insert("a".into(), 2); + let mut current = floor.clone(); + assert!(current.dominates(&floor)); + current.bindings.insert("a".into(), 1); + assert!(!current.dominates(&floor)); + current.bindings.clear(); + assert!(!current.dominates(&floor)); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn activation_precedes_first_mutation_and_makes_lifecycle_visible() { + 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 = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("activation-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + let operation_id = Uuid::new_v4(); + let fingerprint = [0x71; 32]; + + db.activate_authorization_domain(community, operation_id, fingerprint) + .await + .expect("domain activation"); + db.activate_authorization_domain(community, operation_id, fingerprint) + .await + .expect("activation retry"); + let before = db + .authorization_invalidation_snapshot(community) + .await + .expect("initial snapshot"); + + sqlx::query("INSERT INTO relay_members (community_id,pubkey,role) VALUES ($1,$2,'member')") + .bind(community.as_uuid()) + .bind("11".repeat(32)) + .execute(&db.pool) + .await + .expect("first protected lifecycle mutation"); + let after = db + .authorization_invalidation_snapshot(community) + .await + .expect("advanced snapshot"); + + assert_eq!(after.generation, before.generation + 1); + assert_eq!( + db.authorization_operation_receipt_fingerprint(community, operation_id) + .await + .expect("activation receipt"), + Some(fingerprint) + ); + let (atomic_receipt, atomic_vector) = db + .authorization_receipt_and_version_vector(community, operation_id) + .await + .expect("receipt and vector snapshot"); + assert_eq!(atomic_receipt, Some(fingerprint)); + assert_eq!( + atomic_vector, + db.authorization_version_vector(community) + .await + .expect("stable vector after snapshot") + ); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn git_policy_replacement_advances_restore_authority() { + 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 = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("git-policy-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("activate protected domain"); + let before = db + .authorization_version_vector(community) + .await + .expect("pre-policy vector"); + let event_id = Sha256::digest(Uuid::new_v4().as_bytes()).to_vec(); + let owner = [0x42_u8; 32]; + 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,'repo')", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .bind(owner.as_slice()) + .bind(serde_json::json!([["d", "repo"], ["protected", "true"]])) + .bind(vec![0_u8; 64]) + .execute(&db.pool) + .await + .expect("protected Git policy"); + let inserted = db + .authorization_version_vector(community) + .await + .expect("inserted policy vector"); + assert!(inserted.authority_epoch > before.authority_epoch); + assert!(inserted.dominates(&before)); + assert!(!before.dominates(&inserted)); + + sqlx::query( + "UPDATE events SET deleted_at=clock_timestamp() \ + WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(&event_id) + .execute(&db.pool) + .await + .expect("retire protected Git policy"); + let retired = db + .authorization_version_vector(community) + .await + .expect("retired policy vector"); + assert!(retired.authority_epoch > inserted.authority_epoch); + assert!(!inserted.dominates(&retired)); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 13fe052805..fe30dcc84a 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -183,6 +183,36 @@ pub async fn create_channel_with_id( description: Option<&str>, created_by: &[u8], ttl_seconds: Option, +) -> Result<(ChannelRecord, bool)> { + let mut tx = pool.begin().await?; + let result = create_channel_with_id_tx( + &mut tx, + community_id, + channel_id, + name, + channel_type, + visibility, + description, + created_by, + ttl_seconds, + ) + .await?; + tx.commit().await?; + Ok(result) +} + +/// Transaction-aware variant of [`create_channel_with_id`]. +#[allow(clippy::too_many_arguments)] +pub async fn create_channel_with_id_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + description: Option<&str>, + created_by: &[u8], + ttl_seconds: Option, ) -> Result<(ChannelRecord, bool)> { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -202,8 +232,6 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = pool.begin().await?; - let rows_affected = sqlx::query( r#" INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) @@ -220,7 +248,7 @@ pub async fn create_channel_with_id( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(&mut **tx) .await? .rows_affected(); @@ -242,7 +270,7 @@ pub async fn create_channel_with_id( .bind(channel_id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } @@ -260,11 +288,10 @@ pub async fn create_channel_with_id( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let record = row_to_channel_record(row)?; - tx.commit().await?; Ok((record, was_created)) } @@ -351,7 +378,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, @@ -386,14 +413,7 @@ pub async fn add_member( role: MemberRole, invited_by: Option<&[u8]>, ) -> Result { - validate_member_pubkey(pubkey)?; - let mut tx = pool.begin().await?; - - // First statement: serialize the whole role-check / owner-count / upsert - // sequence against concurrent membership writes on this channel. - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; - let record = add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await?; tx.commit().await?; Ok(record) @@ -418,7 +438,7 @@ pub enum ChannelAdmissionOutcome { IdentityBindingRequired, } -/// Add a channel member and optional corporate identity binding in one transaction. +/// Add a channel member and optional relay-verified identity binding atomically. pub async fn add_member_with_identity( pool: &PgPool, community_id: CommunityId, @@ -436,11 +456,11 @@ pub async fn add_member_with_identity( } let mut tx = pool.begin().await?; - // Keep this first: every channel membership writer shares this lock order. acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; - let member = - match add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await { + match add_member_after_lock_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by) + .await + { Ok(member) => member, Err(error) => { tx.rollback().await?; @@ -491,7 +511,24 @@ fn validate_member_pubkey(pubkey: &[u8]) -> Result<()> { Ok(()) } -async fn add_member_tx( +/// Transaction-aware variant of [`add_member`]. +/// +/// This function owns the channel membership lock. Callers that already hold +/// it use the private after-lock helper so the lock order remains exact. +pub async fn add_member_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: MemberRole, + invited_by: Option<&[u8]>, +) -> Result { + validate_member_pubkey(pubkey)?; + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + add_member_after_lock_tx(tx, community_id, channel_id, pubkey, role, invited_by).await +} + +async fn add_member_after_lock_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, channel_id: Uuid, @@ -647,39 +684,54 @@ async fn add_member_tx( /// actor could commit after their role was read and this removal would proceed on /// a stale elevated role. /// -/// The `is_agent_owner` lookup deliberately runs *before* the transaction opens: -/// it borrows a second connection from `pool`, and issuing it while holding the -/// lock could deadlock against ourselves on a small pool. That is safe because -/// `agent_owner_pubkey` is immutable — [`crate::user::set_agent_owner`] only -/// updates it when it `IS NULL` (first-mint-wins), so its value cannot change -/// under us and needs no serialization. +/// The immutable agent-owner relationship is read on the caller-owned +/// transaction connection so this operation also composes with a sealed +/// authorization transaction without borrowing a second pool connection. pub async fn remove_member( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], +) -> Result<()> { + let mut tx = pool.begin().await?; + remove_member_tx(&mut tx, community_id, channel_id, pubkey, actor_pubkey).await?; + tx.commit().await?; + Ok(()) +} + +/// Transaction-aware variant of [`remove_member`]. +pub async fn remove_member_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + actor_pubkey: &[u8], ) -> Result<()> { let is_self_remove = pubkey == actor_pubkey; - // Immutable, and must not be queried while holding the lock (second pool - // connection). Resolved up front so every *mutable* authorization read can - // sit behind the serialization point below. let actor_is_agent_owner = if is_self_remove { false } else { - crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? + sqlx::query_scalar::<_, bool>( + "SELECT agent_owner_pubkey = $3 FROM users \ + WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(actor_pubkey) + .fetch_optional(&mut **tx) + .await? + .unwrap_or(false) }; - let mut tx = pool.begin().await?; - // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key // as `add_member`). - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx, community_id, channel_id).await?; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) + let actor_role_str = get_active_role_tx(tx, community_id, channel_id, actor_pubkey) .await? .ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?; let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { @@ -695,7 +747,7 @@ pub async fn remove_member( // Defense-in-depth: prevent removing the last owner regardless of caller. // Callers (REST handlers, NIP-29 handlers) also check this, but the DB // layer enforces it as the final safety net. - let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let target_role = get_active_role_tx(tx, community_id, channel_id, pubkey).await?; if target_role.as_deref() == Some("owner") { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members \ @@ -703,7 +755,7 @@ pub async fn remove_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -724,14 +776,13 @@ pub async fn remove_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(&mut *tx) + .execute(&mut **tx) .await?; if result.rows_affected() == 0 { return Err(DbError::MemberNotFound(channel_id)); } - tx.commit().await?; Ok(()) } @@ -873,6 +924,84 @@ pub async fn get_accessible_channel_ids( .collect() } +/// Revalidate one actor's current read access to a channel in one database +/// statement. Open channels are readable by any authenticated relay actor; +/// private channels require an active membership. Deleted channels deny. +/// +/// This intentionally bypasses application caches. Callers use it at an +/// outbound release boundary after asynchronous fetch or queueing work. +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. Aggregate disclosures use this at their final release boundary +/// so authority for an earlier channel cannot go stale while later channels +/// are checked one at a time. +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) +} + /// Lists channels in a community, optionally filtered by visibility string. pub async fn list_channels( pool: &PgPool, @@ -1251,6 +1380,486 @@ pub struct ChannelUpdate { pub ttl_seconds: Option>, } +/// Transaction-owned NIP-29 channel mutation selected by the relay after +/// protocol-shape validation. Authorization is rechecked from locked rows in +/// [`apply_nip29_mutation_tx`]. +pub enum Nip29Mutation { + /// Create a channel and bootstrap the actor as its owner. + Create { + /// Stable client- or event-derived channel identifier. + channel_id: Uuid, + /// Canonical display name. + name: String, + /// Channel type. + channel_type: ChannelType, + /// Initial visibility. + visibility: ChannelVisibility, + /// Optional description. + description: Option, + /// Optional ephemeral lifetime. + ttl_seconds: Option, + }, + /// Add a member or change an active member's role. + PutUser { + /// Channel identifier. + channel_id: Uuid, + /// Target member key. + target: Vec, + /// Explicit role, or preserve/default when absent. + role: Option, + }, + /// Remove a member. + RemoveUser { + /// Channel identifier. + channel_id: Uuid, + /// Target member key. + target: Vec, + }, + /// Atomically edit channel metadata. + EditMetadata { + /// Channel identifier. + channel_id: Uuid, + /// Durable metadata columns. + updates: ChannelUpdate, + /// Optional topic replacement. + topic: Option, + /// Optional purpose replacement. + purpose: Option, + /// Optional archive transition. + archived: Option, + }, + /// Soft-delete a group and its relay-authored discovery rows. + DeleteGroup { + /// Channel identifier. + channel_id: Uuid, + /// Relay key used to scope discovery cleanup. + relay_pubkey: Vec, + }, + /// Join an open channel without changing an existing role. + Join { + /// Channel identifier. + channel_id: Uuid, + }, + /// Leave a channel without implicit membership creation. + Leave { + /// Channel identifier. + channel_id: Uuid, + }, +} + +/// Durable result of a transaction-owned NIP-29 projection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Nip29MutationOutcome { + /// Affected channel. + pub channel_id: Uuid, + /// Whether protected business state changed. + pub changed: bool, + /// Whether membership visibility changed and caches must be invalidated. + pub membership_changed: bool, + /// Whether channel visibility or lifecycle caches must be invalidated. + pub channel_changed: bool, +} + +async fn actor_owns_active_owner_agent_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + actor: &[u8], +) -> Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( \ + SELECT 1 FROM channel_members cm \ + JOIN users u ON u.community_id = cm.community_id AND u.pubkey = cm.pubkey \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 \ + AND cm.role = 'owner' AND cm.removed_at IS NULL \ + AND u.agent_owner_pubkey = $3 \ + )", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(actor) + .fetch_one(&mut **tx) + .await?) +} + +async fn update_channel_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + mut updates: ChannelUpdate, +) -> Result<()> { + if let Some(name) = updates.name.as_mut() { + *name = buzz_core::channel::canonical_channel_name(name).to_owned(); + if name.is_empty() { + return Err(DbError::InvalidData("channel name is required".into())); + } + } + if updates.name.is_none() + && updates.description.is_none() + && updates.visibility.is_none() + && updates.ttl_seconds.is_none() + { + return Ok(()); + } + if updates.ttl_seconds.is_some() { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "buzz_channel_ttl:{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx) + .await?; + } + let result = sqlx::query( + "UPDATE channels SET \ + name = COALESCE($1, name), \ + description = COALESCE($2, description), \ + visibility = COALESCE($3::channel_visibility, visibility), \ + ttl_seconds = CASE WHEN $4 THEN $5 ELSE ttl_seconds END, \ + ttl_deadline = CASE WHEN $4 THEN CASE WHEN $5 IS NULL THEN NULL \ + ELSE NOW() + ($5 || ' seconds')::interval END ELSE ttl_deadline END, \ + updated_at = NOW() \ + WHERE community_id = $6 AND id = $7 AND deleted_at IS NULL", + ) + .bind(updates.name) + .bind(updates.description) + .bind(updates.visibility) + .bind(updates.ttl_seconds.is_some()) + .bind(updates.ttl_seconds.flatten()) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::ChannelNotFound(channel_id)); + } + Ok(()) +} + +/// Apply one NIP-29 durable projection on the same transaction that owns the +/// sealed authorization permit and event receipt. +pub async fn apply_nip29_mutation_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + actor: &[u8], + mutation: Nip29Mutation, +) -> Result { + if actor.len() != 32 { + return Err(DbError::InvalidData("actor pubkey must be 32 bytes".into())); + } + match mutation { + Nip29Mutation::Create { + channel_id, + name, + channel_type, + visibility, + description, + ttl_seconds, + } => { + let (_, changed) = create_channel_with_id_tx( + tx, + community_id, + channel_id, + &name, + channel_type, + visibility, + description.as_deref(), + actor, + ttl_seconds, + ) + .await?; + if !changed { + return Err(DbError::InvalidData("channel already exists".into())); + } + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: changed, + }) + } + Nip29Mutation::PutUser { + channel_id, + target, + role, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx, community_id, channel_id).await?; + let existing = get_active_role_tx(tx, community_id, channel_id, &target).await?; + let effective_role = match (role, existing.as_deref()) { + (Some(role), _) => role, + (None, Some(role)) => role.parse().map_err(|_| { + DbError::InvalidData(format!("invalid role in database: {role}")) + })?, + (None, None) => MemberRole::Member, + }; + if target != actor { + // Establish a row-level serialization point even when the + // target has never published a profile. Without this insert, + // `FOR SHARE` below cannot lock a missing row and a concurrent + // first profile could commit a restrictive policy before this + // membership transaction commits. + crate::user::ensure_user_tx(tx, community_id, &target).await?; + let policy = sqlx::query( + "SELECT channel_add_policy::text AS policy, agent_owner_pubkey \ + FROM users WHERE community_id = $1 AND pubkey = $2 \ + FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&target) + .fetch_optional(&mut **tx) + .await?; + if let Some(policy) = policy { + let value: String = policy.try_get("policy")?; + let owner: Option> = policy.try_get("agent_owner_pubkey")?; + match value.as_str() { + "owner_only" if owner.as_deref() != Some(actor) => { + return Err(DbError::AccessDenied( + "only the agent owner may add this member".into(), + )); + } + "nobody" => { + return Err(DbError::AccessDenied( + "this member has disabled external channel additions".into(), + )); + } + _ => {} + } + } + } + let before = existing; + // The lock is reentrant for this transaction; `add_member_tx` + // retains the complete role and last-owner checks. + add_member_tx( + tx, + community_id, + channel_id, + &target, + effective_role, + Some(actor), + ) + .await?; + let changed = before.as_deref() != Some(effective_role.as_str()); + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: channel.visibility == "open" && before.is_none(), + }) + } + Nip29Mutation::RemoveUser { channel_id, target } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + get_channel_tx(tx, community_id, channel_id).await?; + if target != actor + && get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .is_none() + { + return Err(DbError::AccessDenied( + "actor is not an active member".into(), + )); + } + remove_member_tx(tx, community_id, channel_id, &target, actor).await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + Nip29Mutation::EditMetadata { + channel_id, + updates, + topic, + purpose, + archived, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let privileged = updates.name.is_some() + || updates.description.is_some() + || updates.visibility.is_some() + || updates.ttl_seconds.is_some() + || archived.is_some(); + let role = get_active_role_tx(tx, community_id, channel_id, actor).await?; + if privileged { + let elevated = role + .as_deref() + .and_then(|role| role.parse::().ok()) + .is_some_and(|role| role.is_elevated()); + if !elevated + && !actor_owns_active_owner_agent_tx(tx, community_id, channel_id, actor) + .await? + { + return Err(DbError::AccessDenied( + "actor is not authorized to edit channel metadata".into(), + )); + } + } else if (topic.is_some() || purpose.is_some()) && role.is_none() { + return Err(DbError::AccessDenied( + "actor is not an active member".into(), + )); + } + update_channel_tx(tx, community_id, channel_id, updates).await?; + if let Some(topic) = topic { + sqlx::query( + "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", + ) + .bind(topic) + .bind(actor) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + } + if let Some(purpose) = purpose { + sqlx::query( + "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", + ) + .bind(purpose) + .bind(actor) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await?; + } + if let Some(archived) = archived { + let result = if archived { + sqlx::query( + "UPDATE channels SET archived_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL AND archived_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + } else { + sqlx::query( + "UPDATE channels SET archived_at = NULL, ttl_deadline = CASE \ + WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \ + ELSE ttl_deadline END WHERE community_id = $1 AND id = $2 \ + AND deleted_at IS NULL AND archived_at IS NOT NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + }; + if result.rows_affected() == 0 { + return Err(DbError::AccessDenied( + "channel archive state did not permit the transition".into(), + )); + } + } + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: false, + channel_changed: true, + }) + } + Nip29Mutation::DeleteGroup { + channel_id, + relay_pubkey, + } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + sqlx::query("SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let owner = get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .as_deref() + == Some("owner"); + if !owner + && !actor_owns_active_owner_agent_tx(tx, community_id, channel_id, actor).await? + { + return Err(DbError::AccessDenied( + "only an owner may delete a group".into(), + )); + } + let changed = sqlx::query( + "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 \ + AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(&mut **tx) + .await? + .rows_affected() + > 0; + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL \ + AND kind IN (39000, 39001, 39002)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(relay_pubkey) + .execute(&mut **tx) + .await?; + Ok(Nip29MutationOutcome { + channel_id, + changed, + membership_changed: changed, + channel_changed: changed, + }) + } + Nip29Mutation::Join { channel_id } => { + acquire_channel_membership_lock(tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx, community_id, channel_id).await?; + if channel.visibility != "open" { + return Err(DbError::AccessDenied("channel is private".into())); + } + if get_active_role_tx(tx, community_id, channel_id, actor) + .await? + .is_some() + { + return Ok(Nip29MutationOutcome { + channel_id, + changed: false, + membership_changed: false, + channel_changed: false, + }); + } + add_member_tx( + tx, + community_id, + channel_id, + actor, + MemberRole::Member, + None, + ) + .await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + Nip29Mutation::Leave { channel_id } => { + remove_member_tx(tx, community_id, channel_id, actor, actor).await?; + Ok(Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed: true, + channel_changed: true, + }) + } + } +} + /// Updates channel metadata dynamically. /// /// At least one field must be provided; returns `InvalidData` otherwise. @@ -1587,23 +2196,100 @@ pub async fn get_member_role( Ok(row.map(|r| r.try_get("role")).transpose()?) } -/// Archive ephemeral channels whose TTL deadline has passed. +/// Get the active role on the caller's transaction snapshot. /// -/// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the -/// `archived_at IS NULL` guard prevents double-archiving even if called -/// concurrently from multiple relay pods. -pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { - let rows = sqlx::query( - "UPDATE channels AS ch SET archived_at = NOW() \ - FROM communities AS c \ - WHERE ch.community_id = c.id \ - AND ch.ttl_seconds IS NOT NULL \ +/// Permission decisions that combine an event-owned binding with membership +/// use this together with [`crate::event::query_events_tx`] after selecting a +/// repeatable-read transaction, so both facts come from one database 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()?) +} + +/// Lock and revalidate the ordinary member-or-open channel write predicate in +/// the caller's authorization transaction. +pub async fn require_channel_write_authority_tx( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + actor: &[u8], +) -> Result<()> { + let row = sqlx::query( + "SELECT visibility::text AS visibility, archived_at FROM channels \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; + let archived_at: Option> = row.try_get("archived_at")?; + if archived_at.is_some() { + return Err(DbError::AccessDenied("channel is archived".into())); + } + let 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(actor) + .fetch_optional(&mut **transaction) + .await?; + let visibility: String = row.try_get("visibility")?; + if role.is_none() && visibility != "open" { + return Err(DbError::AccessDenied( + "actor is not a channel member".into(), + )); + } + Ok(()) +} + +/// Archive ephemeral channels whose TTL deadline has passed. +/// +/// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the +/// `archived_at IS NULL` guard prevents double-archiving even if called +/// concurrently from multiple relay pods. +pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { + reap_expired_ephemeral_channels_excluding(pool, &[]).await +} + +/// Archive expired ephemeral channels except exact protected domains. +/// +/// The exclusion predicate is part of the `UPDATE`, so an Enforce row cannot +/// be claimed and mutated between an application-side mode check and commit. +pub async fn reap_expired_ephemeral_channels_excluding( + pool: &PgPool, + excluded_communities: &[Uuid], +) -> Result> { + let rows = sqlx::query( + "UPDATE channels AS ch SET archived_at = NOW() \ + FROM communities AS c \ + WHERE ch.community_id = c.id \ + AND NOT (ch.community_id = ANY($1::uuid[])) \ + AND ch.ttl_seconds IS NOT NULL \ AND ch.ttl_deadline < NOW() \ AND ch.archived_at IS NULL \ AND ch.deleted_at IS NULL \ AND c.archived_at IS NULL \ RETURNING ch.community_id, c.host, ch.id", ) + .bind(excluded_communities) .fetch_all(pool) .await?; @@ -1624,14 +2310,18 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result PgPool { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| 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()); PgPool::connect(&database_url) .await .expect("connect to test DB") @@ -1714,15 +2404,6 @@ mod tests { } } - async fn trusted_assertion_count(pool: &PgPool, community: CommunityId) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND kind = $2") - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_USER_TRUSTED_ASSERTION as i32) - .fetch_one(pool) - .await - .expect("trusted assertion count") - } - async fn active_membership_count( pool: &PgPool, community: CommunityId, @@ -1749,7 +2430,6 @@ mod tests { let community_id = make_test_community(&pool).await; let community = CommunityId::from_uuid(community_id); let owner = random_pubkey(); - let non_member_inviter = random_pubkey(); let joiner = random_pubkey(); let channel = create_test_channel( &pool, @@ -1771,7 +2451,7 @@ mod tests { channel.id, &joiner, MemberRole::Member, - Some(&non_member_inviter), + Some(&random_pubkey()), Some(&identity), ) .await @@ -1789,7 +2469,6 @@ mod tests { .expect("binding lookup") .is_none() ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1851,7 +2530,6 @@ mod tests { active_membership_count(&pool, community, channel.id, &joiner).await, 0 ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1893,15 +2571,6 @@ mod tests { active_membership_count(&pool, community, channel.id, &joiner).await, 0 ); - assert!( - crate::identity_binding::get_active_identity_binding_by_pubkey( - &pool, community, &joiner, - ) - .await - .expect("binding lookup") - .is_none() - ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1997,88 +2666,6 @@ mod tests { assert_eq!(binding_count, 1); } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn existing_member_and_non_corporate_paths_remain_idempotent() { - let pool = setup_pool().await; - let community_id = make_test_community(&pool).await; - let community = CommunityId::from_uuid(community_id); - let owner = random_pubkey(); - let existing_member = random_pubkey(); - let non_corporate_joiner = random_pubkey(); - let channel = create_test_channel( - &pool, - community_id, - "unchanged-admission-paths", - ChannelType::Stream, - ChannelVisibility::Private, - None, - &owner, - Some(3600), - ) - .await - .expect("create private huddle"); - - add_member( - &pool, - community, - channel.id, - &existing_member, - MemberRole::Member, - Some(&owner), - ) - .await - .expect("existing member add"); - add_member( - &pool, - community, - channel.id, - &existing_member, - MemberRole::Member, - Some(&owner), - ) - .await - .expect("existing member retry"); - assert_eq!( - active_membership_count(&pool, community, channel.id, &existing_member).await, - 1 - ); - - let outcome = add_member_with_identity( - &pool, - community, - channel.id, - &non_corporate_joiner, - MemberRole::Member, - Some(&owner), - None, - ) - .await - .expect("non-corporate admission"); - assert!(matches!( - outcome, - ChannelAdmissionOutcome::Joined { - identity_binding: None, - .. - } - )); - assert_eq!( - active_membership_count(&pool, community, channel.id, &non_corporate_joiner).await, - 1 - ); - assert!( - crate::identity_binding::get_active_identity_binding_by_pubkey( - &pool, - community, - &non_corporate_joiner, - ) - .await - .expect("binding lookup") - .is_none() - ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); - } - async fn insert_channel_with_id( pool: &PgPool, community_id: Uuid, @@ -2359,6 +2946,14 @@ mod tests { .await .expect("expire channel"); + let excluded = reap_expired_ephemeral_channels_excluding(&pool, &[community_id]) + .await + .expect("run excluded reaper"); + assert!( + !excluded.iter().any(|row| row.channel_id == channel.id), + "an excluded protected domain must remain untouched" + ); + let reaped = reap_expired_ephemeral_channels(&pool) .await .expect("run reaper"); @@ -3161,4 +3756,453 @@ mod tests { .expect("read role after restore"); assert_eq!(restored.as_deref(), Some("owner")); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_create_is_owned_by_the_callers_transaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let actor = random_pubkey(); + let channel_id = Uuid::new_v4(); + let mut tx = pool.begin().await.expect("begin caller transaction"); + let outcome = apply_nip29_mutation_tx( + &mut tx, + community, + &actor, + Nip29Mutation::Create { + channel_id, + name: "sealed-channel".into(), + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Private, + description: None, + ttl_seconds: None, + }, + ) + .await + .expect("create projection"); + assert!(outcome.changed); + tx.rollback().await.expect("authorization rollback"); + + assert!(matches!( + get_channel(&pool, community, channel_id).await, + Err(DbError::ChannelNotFound(_)) + )); + let membership_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM channel_members WHERE community_id = $1 AND channel_id = $2", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .fetch_one(&pool) + .await + .expect("count rolled-back members"); + assert_eq!(membership_count, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_join_retry_is_idempotent_and_never_changes_an_existing_role() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let member = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-join", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + let mut tx = pool.begin().await.expect("begin first join"); + let first = apply_nip29_mutation_tx( + &mut tx, + community, + &member, + Nip29Mutation::Join { + channel_id: channel.id, + }, + ) + .await + .expect("first join"); + assert!(first.changed); + tx.commit().await.expect("commit first join"); + + let mut retry = pool.begin().await.expect("begin retry"); + let repeated = apply_nip29_mutation_tx( + &mut retry, + community, + &member, + Nip29Mutation::Join { + channel_id: channel.id, + }, + ) + .await + .expect("retry join"); + assert!(!repeated.changed); + retry.commit().await.expect("commit retry"); + + let members = get_members(&pool, community, channel.id) + .await + .expect("members"); + assert_eq!( + members + .iter() + .filter(|entry| entry.pubkey == member) + .count(), + 1 + ); + assert_eq!( + members + .iter() + .find(|entry| entry.pubkey == owner) + .map(|entry| entry.role.as_str()), + Some("owner") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_put_user_serializes_with_target_policy_updates() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let actor = random_pubkey(); + let target = random_pubkey(); + ensure_user(&pool, community, &actor) + .await + .expect("ensure actor"); + ensure_user(&pool, community, &target) + .await + .expect("ensure target"); + set_channel_add_policy(&pool, community, &target, "anyone") + .await + .expect("allow external additions"); + let channel = create_test_channel( + &pool, + community_id, + "sealed-target-policy-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &actor, + None, + ) + .await + .expect("create channel"); + + let mut operation = pool.begin().await.expect("begin protected put-user"); + apply_nip29_mutation_tx( + &mut operation, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await + .expect("authorize and stage target membership"); + + let update_pool = pool.clone(); + let update_target = target.clone(); + let mut policy_update = tokio::spawn(async move { + set_channel_add_policy(&update_pool, community, &update_target, "nobody").await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut policy_update) + .await + .is_err(), + "target policy update must wait for the transaction that authorized the addition" + ); + + operation + .commit() + .await + .expect("commit authorized addition before policy update"); + tokio::time::timeout(std::time::Duration::from_secs(10), policy_update) + .await + .expect("policy update proceeds after authorization transaction") + .expect("policy update task") + .expect("policy update succeeds"); + assert!( + is_member(&pool, community, channel.id, &target) + .await + .expect("membership after serialized commit"), + "the authorization transaction won the serialization order" + ); + + let denied_target = random_pubkey(); + ensure_user(&pool, community, &denied_target) + .await + .expect("ensure denied target"); + set_channel_add_policy(&pool, community, &denied_target, "nobody") + .await + .expect("deny external additions first"); + let mut denied = pool.begin().await.expect("begin denied put-user"); + let result = apply_nip29_mutation_tx( + &mut denied, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: denied_target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + denied.rollback().await.expect("rollback denied put-user"); + assert!( + !is_member(&pool, community, channel.id, &denied_target) + .await + .expect("denied target membership"), + "a policy update that commits first must deny without membership" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip29_put_user_serializes_with_first_target_profile() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let actor = random_pubkey(); + ensure_user(&pool, community, &actor) + .await + .expect("ensure actor"); + let channel = create_test_channel( + &pool, + community_id, + "sealed-first-profile-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &actor, + None, + ) + .await + .expect("create channel"); + + // PutUser wins: its create-or-conflict establishes the target row and + // retains that row through membership commit. The first restrictive + // profile must wait and therefore takes effect only afterward. + let target = random_pubkey(); + let mut operation = pool.begin().await.expect("begin protected put-user"); + apply_nip29_mutation_tx( + &mut operation, + community, + &actor, + Nip29Mutation::PutUser { + channel_id: channel.id, + target: target.clone(), + role: Some(MemberRole::Member), + }, + ) + .await + .expect("stage absent target membership"); + + let update_pool = pool.clone(); + let update_target = target.clone(); + let mut first_profile = tokio::spawn(async move { + let mut profile_tx = update_pool.begin().await.expect("begin first profile"); + ensure_user_tx(&mut profile_tx, community, &update_target) + .await + .expect("create or observe target"); + set_channel_add_policy_tx(&mut profile_tx, community, &update_target, "nobody") + .await + .expect("set first profile policy"); + profile_tx.commit().await.expect("commit first profile") + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut first_profile) + .await + .is_err(), + "first target profile must wait for the earlier PutUser transaction" + ); + operation.commit().await.expect("commit absent-target add"); + tokio::time::timeout(std::time::Duration::from_secs(10), first_profile) + .await + .expect("first profile proceeds after membership commit") + .expect("first profile task"); + assert!(is_member(&pool, community, channel.id, &target) + .await + .expect("membership after PutUser-first order")); + + // Profile wins: hold its newly inserted `nobody` row open. PutUser must + // wait at create-or-conflict, then re-read the committed restriction + // and deny before adding membership. + let denied_target = random_pubkey(); + let mut profile_tx = pool.begin().await.expect("begin winning profile"); + ensure_user_tx(&mut profile_tx, community, &denied_target) + .await + .expect("stage first target profile"); + set_channel_add_policy_tx(&mut profile_tx, community, &denied_target, "nobody") + .await + .expect("stage restrictive policy"); + + let put_pool = pool.clone(); + let put_actor = actor.clone(); + let put_target = denied_target.clone(); + let channel_id = channel.id; + let mut put_user = tokio::spawn(async move { + let mut put_tx = put_pool.begin().await.expect("begin waiting put-user"); + let result = apply_nip29_mutation_tx( + &mut put_tx, + community, + &put_actor, + Nip29Mutation::PutUser { + channel_id, + target: put_target, + role: Some(MemberRole::Member), + }, + ) + .await; + match result { + Ok(outcome) => { + put_tx.commit().await.expect("commit unexpected add"); + Ok(outcome) + } + Err(error) => { + put_tx.rollback().await.expect("rollback denied add"); + Err(error) + } + } + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(750), &mut put_user) + .await + .is_err(), + "PutUser must wait for the earlier first-profile transaction" + ); + profile_tx + .commit() + .await + .expect("commit restrictive profile"); + let result = tokio::time::timeout(std::time::Duration::from_secs(10), put_user) + .await + .expect("PutUser proceeds after first profile commit") + .expect("PutUser task"); + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + assert!( + !is_member(&pool, community, channel.id, &denied_target) + .await + .expect("membership after profile-first order"), + "a restrictive first profile must deny without membership" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removed_after_precheck_before_commit_denies_event() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let member = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-write-race", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + None, + ) + .await + .expect("create channel"); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'member', $4)", + ) + .bind(community_id) + .bind(channel.id) + .bind(&member) + .bind(&owner) + .execute(&pool) + .await + .expect("add member"); + + let mut preflight = pool.begin().await.expect("begin preflight"); + require_channel_write_authority_tx(&mut preflight, community, channel.id, &member) + .await + .expect("member passes preflight"); + preflight.rollback().await.expect("release preflight locks"); + + sqlx::query( + "UPDATE channel_members SET removed_at = NOW() \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(community_id) + .bind(channel.id) + .bind(&member) + .execute(&pool) + .await + .expect("remove member between boundaries"); + + let mut operation = pool.begin().await.expect("begin operation"); + let result = + require_channel_write_authority_tx(&mut operation, community, channel.id, &member) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + operation + .rollback() + .await + .expect("rollback denied operation"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn open_to_private_after_precheck_denies_nonmember() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let nonmember = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "sealed-visibility-race", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel"); + + let mut preflight = pool.begin().await.expect("begin preflight"); + require_channel_write_authority_tx(&mut preflight, community, channel.id, &nonmember) + .await + .expect("open channel passes preflight"); + preflight.rollback().await.expect("release preflight locks"); + + sqlx::query( + "UPDATE channels SET visibility = 'private'::channel_visibility \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(channel.id) + .execute(&pool) + .await + .expect("make channel private between boundaries"); + + let mut operation = pool.begin().await.expect("begin operation"); + let result = + require_channel_write_authority_tx(&mut operation, community, channel.id, &nonmember) + .await; + assert!(matches!(result, Err(DbError::AccessDenied(_)))); + operation + .rollback() + .await + .expect("rollback denied operation"); + } } diff --git a/crates/buzz-db/src/client_status.rs b/crates/buzz-db/src/client_status.rs new file mode 100644 index 0000000000..46e70b80e2 --- /dev/null +++ b/crates/buzz-db/src/client_status.rs @@ -0,0 +1,710 @@ +//! Durable, current-only client verification-status revisions. +//! +//! Allocation is transaction-owned: the exact active binding, membership, +//! invalidation generation/floors, database-clock freshness, revision row, +//! authority epoch, and idempotency receipt are committed together. + +use buzz_core::CommunityId; +use sqlx::{Postgres, Row, Transaction}; +use thiserror::Error; +use uuid::Uuid; + +use crate::authorization_invalidation::AuthorizationSelector; +use crate::Db; + +const CURRENT_KIND: &str = "client.status.current.v1"; +const WITHDRAW_KIND: &str = "client.status.withdraw.v1"; + +/// Exact private requirement for one current-status issuance. +pub struct CurrentStatusAllocation<'a> { + /// Server-resolved authorization domain. + pub community_id: CommunityId, + /// Exact event-author key. + pub event_author_pubkey: &'a [u8; 32], + /// Stable active binding ID. + pub binding_id: Uuid, + /// Exact positive binding version. + pub binding_version: u64, + /// Opaque current provider policy version. + pub policy_version: &'a str, + /// Invalidation generation captured before provider evaluation. + pub evaluation_generation: u64, + /// Database-clock freshness boundary. + pub fresh_until: u64, + /// Stable issuance operation ID. + pub operation_id: Uuid, + /// Exact event-input fingerprint. + pub request_fingerprint: [u8; 32], +} + +/// Exact private requirement for an opaque withdrawal. +pub struct WithdrawalStatusAllocation<'a> { + /// Server-resolved authorization domain. + pub community_id: CommunityId, + /// Exact event-author key. + pub event_author_pubkey: &'a [u8; 32], + /// Revision of the actual current issuance being withdrawn. + pub supersedes_revision: u64, + /// Fingerprint of the durable current issuance receipt. + pub issuance_fingerprint: [u8; 32], + /// Stable withdrawal operation ID. + pub operation_id: Uuid, + /// Exact withdrawal-input fingerprint. + pub request_fingerprint: [u8; 32], +} + +/// Result of a transaction-owned revision allocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AllocatedStatusRevision { + /// Strictly positive revision. + pub revision: u64, + /// Domain-wide durable status floor after this allocation. + pub floor: u64, +} + +/// Allocation failure classified by whether PostgreSQL commit was attempted. +#[derive(Debug, Error)] +pub enum ClientStatusAllocationError { + /// Current private authority did not satisfy the exact requirement. + #[error("client status authority is not current")] + NotCurrent, + /// A stable operation ID was reused for different input. + #[error("client status operation conflicts with a committed request")] + ConflictingRetry, + /// Input could not be represented safely. + #[error("client status allocation input is invalid")] + InvalidInput, + /// PostgreSQL failed before commit was attempted; the transaction rolls back. + #[error("client status allocation failed before commit")] + Database(#[source] sqlx::Error), + /// PostgreSQL commit acknowledgement was ambiguous. The receipt decides. + #[error("client status commit acknowledgement is ambiguous")] + CommitUnknown(#[source] sqlx::Error), +} + +impl From for ClientStatusAllocationError { + fn from(error: sqlx::Error) -> Self { + Self::Database(error) + } +} + +impl Db { + /// Read an exact committed status allocation after ambiguous commit acknowledgement. + pub async fn committed_status_revision( + &self, + community_id: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + ) -> Result, ClientStatusAllocationError> { + let row = sqlx::query( + "SELECT request_fingerprint, result_payload 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?; + let Some(row) = row else { return Ok(None) }; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + let payload: Vec = row.try_get("result_payload")?; + if fingerprint.as_slice() != request_fingerprint { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(Some(decode_receipt_payload(&payload)?)) + } + + /// Allocate or replay one strictly monotonic current-status revision. + pub async fn allocate_current_status_revision( + &self, + request: CurrentStatusAllocation<'_>, + ) -> Result { + validate_common( + request.community_id, + request.event_author_pubkey, + request.operation_id, + request.binding_version, + )?; + if request.policy_version.is_empty() + || request.evaluation_generation > i64::MAX as u64 + || request.fresh_until > i64::MAX as u64 + { + return Err(ClientStatusAllocationError::InvalidInput); + } + let mut tx = self.begin_transaction().await.map_err(db_error)?; + lock_scope(&mut tx, request.community_id, request.event_author_pubkey).await?; + let (issuer, subject) = validate_current_authority(&mut tx, &request).await?; + validate_invalidation( + &mut tx, + request.community_id, + request.evaluation_generation, + request.binding_id, + request.binding_version, + request.event_author_pubkey, + request.policy_version, + &issuer, + &subject, + ) + .await?; + if let Some(revision) = replay_revision( + &mut tx, + request.community_id, + request.operation_id, + CURRENT_KIND, + request.request_fingerprint, + ) + .await? + { + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + return Ok(revision); + } + let allocated = next_revision(&mut tx, request.community_id).await?; + sqlx::query( + "INSERT INTO client_status_revisions \ + (community_id, event_author_pubkey, revision, disposition, binding_id, binding_version) \ + VALUES ($1, $2, $3, 'current', $4, $5) \ + ON CONFLICT (community_id, event_author_pubkey) DO UPDATE SET \ + revision=EXCLUDED.revision, disposition='current', binding_id=EXCLUDED.binding_id, \ + binding_version=EXCLUDED.binding_version, supersedes_revision=NULL, \ + updated_at=clock_timestamp()", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .bind(allocated as i64) + .bind(request.binding_id) + .bind(request.binding_version as i64) + .execute(&mut *tx) + .await?; + insert_receipt( + &mut tx, + request.community_id, + request.operation_id, + CURRENT_KIND, + request.request_fingerprint, + allocated, + ) + .await?; + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + Ok(AllocatedStatusRevision { + revision: allocated, + floor: allocated, + }) + } + + /// Allocate or replay a withdrawal strictly after its exact current receipt. + pub async fn allocate_withdrawn_status_revision( + &self, + request: WithdrawalStatusAllocation<'_>, + ) -> Result { + validate_common( + request.community_id, + request.event_author_pubkey, + request.operation_id, + request.supersedes_revision, + )?; + let mut tx = self.begin_transaction().await.map_err(db_error)?; + lock_scope(&mut tx, request.community_id, request.event_author_pubkey).await?; + if let Some(revision) = replay_revision( + &mut tx, + request.community_id, + request.operation_id, + WITHDRAW_KIND, + request.request_fingerprint, + ) + .await? + { + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + return Ok(revision); + } + let issuance_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_kind=$2 AND request_fingerprint=$3 \ + AND octet_length(result_payload) IN (8,16) \ + AND substring(result_payload FROM 1 FOR 8)=$4)", + ) + .bind(request.community_id.as_uuid()) + .bind(CURRENT_KIND) + .bind(request.issuance_fingerprint.as_slice()) + .bind(request.supersedes_revision.to_be_bytes().as_slice()) + .fetch_one(&mut *tx) + .await?; + if !issuance_exists { + return Err(ClientStatusAllocationError::NotCurrent); + } + let row: Option<(i64, String, Option)> = sqlx::query_as( + "SELECT revision, disposition, supersedes_revision FROM client_status_revisions \ + WHERE community_id=$1 AND event_author_pubkey=$2 FOR UPDATE", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .fetch_optional(&mut *tx) + .await?; + let Some((revision, disposition, prior_supersedes)) = row else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let receipt_revision = request.supersedes_revision as i64; + let superseded_revision = if disposition == "current" { + if receipt_revision > revision { + return Err(ClientStatusAllocationError::NotCurrent); + } + revision + } else if disposition == "withdrawn" + && prior_supersedes.is_some_and(|superseded| receipt_revision <= superseded) + && revision > receipt_revision + { + prior_supersedes.expect("withdrawn status has a superseded revision") + } else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let floor: i64 = sqlx::query_scalar( + "SELECT status_revision FROM authorization_authority_epochs \ + WHERE community_id=$1 FOR UPDATE", + ) + .bind(request.community_id.as_uuid()) + .fetch_one(&mut *tx) + .await?; + let allocated = if disposition == "current" || revision < floor { + next_revision(&mut tx, request.community_id).await? + } else { + revision as u64 + }; + if allocated <= superseded_revision as u64 { + return Err(ClientStatusAllocationError::NotCurrent); + } + sqlx::query( + "UPDATE client_status_revisions SET revision=$3, disposition='withdrawn', \ + binding_id=NULL, binding_version=NULL, supersedes_revision=$4, \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_author_pubkey=$2", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .bind(allocated as i64) + .bind(superseded_revision) + .execute(&mut *tx) + .await?; + insert_receipt( + &mut tx, + request.community_id, + request.operation_id, + WITHDRAW_KIND, + request.request_fingerprint, + allocated, + ) + .await?; + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + Ok(AllocatedStatusRevision { + revision: allocated, + floor: allocated, + }) + } +} + +fn validate_common( + community_id: CommunityId, + pubkey: &[u8; 32], + operation_id: Uuid, + positive: u64, +) -> Result<(), ClientStatusAllocationError> { + if community_id.as_uuid().is_nil() + || operation_id.is_nil() + || positive == 0 + || positive > i64::MAX as u64 + || pubkey.iter().all(|byte| *byte == 0) + { + return Err(ClientStatusAllocationError::InvalidInput); + } + Ok(()) +} + +fn db_error(error: crate::DbError) -> ClientStatusAllocationError { + match error { + crate::DbError::Sqlx(error) => ClientStatusAllocationError::Database(error), + _ => ClientStatusAllocationError::InvalidInput, + } +} + +async fn lock_scope( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + pubkey: &[u8; 32], +) -> Result<(), ClientStatusAllocationError> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "client-status:{}:{}", + community_id, + hex::encode(pubkey) + )) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn validate_current_authority( + tx: &mut Transaction<'static, Postgres>, + request: &CurrentStatusAllocation<'_>, +) -> Result<(String, String), ClientStatusAllocationError> { + let row: Option<(String, String)> = sqlx::query_as( + "SELECT binding.issuer, binding.uid FROM identity_bindings binding \ + JOIN identity_principals principal ON principal.community_id=binding.community_id \ + AND principal.issuer=binding.issuer AND principal.uid=binding.uid \ + JOIN relay_members member ON member.community_id=binding.community_id \ + AND member.pubkey=encode(binding.pubkey, 'hex') \ + WHERE binding.community_id=$1 AND binding.binding_id=$2 AND binding.pubkey=$3 \ + AND binding.binding_version=$4 AND binding.binding_state='active' \ + AND principal.disabled_at IS NULL \ + AND NOT EXISTS (SELECT 1 FROM identity_revoked_keys revoked \ + WHERE revoked.community_id=binding.community_id AND revoked.pubkey=binding.pubkey) \ + FOR SHARE OF binding, principal, member", + ) + .bind(request.community_id.as_uuid()) + .bind(request.binding_id) + .bind(request.event_author_pubkey.as_slice()) + .bind(request.binding_version as i64) + .fetch_optional(&mut **tx) + .await?; + let Some(principal) = row else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let fresh: bool = + sqlx::query_scalar("SELECT clock_timestamp() < to_timestamp($1::double precision)") + .bind(request.fresh_until as f64) + .fetch_one(&mut **tx) + .await?; + if !fresh { + return Err(ClientStatusAllocationError::NotCurrent); + } + Ok(principal) +} + +#[allow(clippy::too_many_arguments)] +async fn validate_invalidation( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + evaluation_generation: u64, + binding_id: Uuid, + binding_version: u64, + pubkey: &[u8; 32], + policy_version: &str, + issuer: &str, + subject: &str, +) -> Result<(), ClientStatusAllocationError> { + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + if generation != Some(evaluation_generation as i64) { + return Err(ClientStatusAllocationError::NotCurrent); + } + let selectors = [ + AuthorizationSelector::domain(), + AuthorizationSelector::principal(issuer, subject) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + AuthorizationSelector::nostr_key(*pubkey), + AuthorizationSelector::binding(binding_id, binding_version) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + AuthorizationSelector::policy_version(policy_version) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + ]; + for selector in selectors { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors WHERE community_id=$1 \ + AND selector_kind=$2 AND selector_fingerprint=$3 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(selector.kind().as_str()) + .bind(selector.fingerprint().as_slice()) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { continue }; + let floor_generation: i64 = row.try_get("generation")?; + let sticky: bool = row.try_get("sticky_deny")?; + let version_floor: Option = row.try_get("binding_version_floor")?; + if sticky + || floor_generation > evaluation_generation as i64 + || version_floor.is_some_and(|floor| binding_version <= floor as u64) + { + return Err(ClientStatusAllocationError::NotCurrent); + } + } + Ok(()) +} + +async fn replay_revision( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: Uuid, + operation_kind: &str, + request_fingerprint: [u8; 32], +) -> Result, ClientStatusAllocationError> { + let row = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { return Ok(None) }; + let kind: String = row.try_get("operation_kind")?; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + let payload: Vec = row.try_get("result_payload")?; + if kind != operation_kind || fingerprint.as_slice() != request_fingerprint { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(Some(decode_receipt_payload(&payload)?)) +} + +fn decode_receipt_payload( + payload: &[u8], +) -> Result { + let (revision_bytes, floor_bytes) = match payload.len() { + // Compatibility with receipts written before allocation-time floors + // were retained. The allocation revision was also its floor. + 8 => (&payload[..8], &payload[..8]), + 16 => (&payload[..8], &payload[8..16]), + _ => return Err(ClientStatusAllocationError::ConflictingRetry), + }; + let revision = u64::from_be_bytes( + revision_bytes + .try_into() + .map_err(|_| ClientStatusAllocationError::ConflictingRetry)?, + ); + let floor = u64::from_be_bytes( + floor_bytes + .try_into() + .map_err(|_| ClientStatusAllocationError::ConflictingRetry)?, + ); + if revision == 0 || floor == 0 || revision < floor { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(AllocatedStatusRevision { revision, floor }) +} + +async fn next_revision( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, +) -> Result { + let revision: i64 = sqlx::query_scalar( + "UPDATE authorization_authority_epochs SET \ + authority_epoch=authority_epoch+1, status_revision=status_revision+1, \ + updated_at=clock_timestamp() WHERE community_id=$1 RETURNING status_revision", + ) + .bind(community_id.as_uuid()) + .fetch_one(&mut **tx) + .await?; + u64::try_from(revision).map_err(|_| ClientStatusAllocationError::InvalidInput) +} + +async fn insert_receipt( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: Uuid, + operation_kind: &str, + request_fingerprint: [u8; 32], + revision: u64, +) -> Result<(), ClientStatusAllocationError> { + let mut result_payload = Vec::with_capacity(16); + result_payload.extend_from_slice(&revision.to_be_bytes()); + result_payload.extend_from_slice(&revision.to_be_bytes()); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1,$2,$3,$4,1,$5,clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(operation_kind) + .bind(request_fingerprint.as_slice()) + .bind(result_payload) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn current_replay_withdrawal_and_revocation_are_transaction_owned() { + 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 = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let binding_id = Uuid::new_v4(); + let author = [0x41; 32]; + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("status-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + sqlx::query("INSERT INTO identity_principals (community_id,issuer,uid) VALUES ($1,$2,$3)") + .bind(community.as_uuid()) + .bind("https://idp.example") + .bind("subject") + .execute(&db.pool) + .await + .expect("principal"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,binding_id,binding_version, \ + binding_state,binding_provenance) \ + VALUES ($1,$2,$3,$4,'jwt_npub',$5,1,'active','attested_key')", + ) + .bind(community.as_uuid()) + .bind("https://idp.example") + .bind("subject") + .bind(author.as_slice()) + .bind(binding_id) + .execute(&db.pool) + .await + .expect("binding"); + sqlx::query("INSERT INTO relay_members (community_id,pubkey,role) VALUES ($1,$2,'member')") + .bind(community.as_uuid()) + .bind(hex::encode(author)) + .execute(&db.pool) + .await + .expect("member"); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1) \ + ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("invalidation domain"); + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("generation"); + let fresh_until = chrono::Utc::now().timestamp() as u64 + 300; + let operation_id = Uuid::new_v4(); + let allocate = |operation_id, fingerprint| CurrentStatusAllocation { + community_id: community, + event_author_pubkey: &author, + binding_id, + binding_version: 1, + policy_version: "policy-v1", + evaluation_generation: generation as u64, + fresh_until, + operation_id, + request_fingerprint: fingerprint, + }; + let first = db + .allocate_current_status_revision(allocate(operation_id, [1; 32])) + .await + .expect("first current"); + let replay = db + .allocate_current_status_revision(allocate(operation_id, [1; 32])) + .await + .expect("exact replay"); + assert_eq!(first, replay); + let second = db + .allocate_current_status_revision(allocate(Uuid::new_v4(), [2; 32])) + .await + .expect("new issuance"); + assert!(second.revision > first.revision); + let withdrawal_operation = Uuid::new_v4(); + let withdrawn = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [2; 32], + operation_id: withdrawal_operation, + request_fingerprint: [3; 32], + }) + .await + .expect("withdrawal"); + assert!(withdrawn.revision > second.revision); + let fanout = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: first.revision, + issuance_fingerprint: [1; 32], + operation_id: Uuid::new_v4(), + request_fingerprint: [4; 32], + }) + .await + .expect("older displayed current receives the same withdrawal"); + assert_eq!(fanout, withdrawn); + + sqlx::query( + "UPDATE authorization_authority_epochs \ + SET authority_epoch=authority_epoch+1, status_revision=status_revision+1 \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("advance unrelated durable status floor"); + let delayed_replay = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [2; 32], + operation_id: withdrawal_operation, + request_fingerprint: [3; 32], + }) + .await + .expect("exact delayed fan-out replay retains allocation-time floor"); + assert_eq!(delayed_replay, withdrawn); + + let reissued = db + .allocate_current_status_revision(allocate(Uuid::new_v4(), [6; 32])) + .await + .expect("a fresh current status can replace a withdrawn projection"); + assert!(reissued.revision > withdrawn.revision); + let row: (String, Option) = sqlx::query_as( + "SELECT disposition, supersedes_revision FROM client_status_revisions \ + WHERE community_id=$1 AND event_author_pubkey=$2", + ) + .bind(community.as_uuid()) + .bind(author.as_slice()) + .fetch_one(&db.pool) + .await + .expect("reissued projection"); + assert_eq!(row, ("current".to_owned(), None)); + + assert!(matches!( + db.allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [9; 32], + operation_id: Uuid::new_v4(), + request_fingerprint: [5; 32], + }) + .await, + Err(ClientStatusAllocationError::NotCurrent) + )); + } +} 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/lib.rs b/crates/buzz-db/src/lib.rs index 590590a345..e69d57ab0a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -15,8 +15,16 @@ 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; +/// Durable provider-neutral authorization invalidation authority. +pub mod authorization_invalidation; +/// Restore-independent high-water snapshots for protected authority. +pub mod authorization_version; /// Channel and membership persistence. pub mod channel; +/// Transaction-owned current-only client verification-status revisions. +pub mod client_status; /// Direct message channel persistence. pub mod dm; /// Database error types. @@ -39,6 +47,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; +/// Durable reconciliation for optional relay-authored identity projections. +pub mod public_projection; /// Community-scoped push lease and durable wake-outbox persistence. pub mod push; /// Reaction persistence. @@ -69,7 +83,7 @@ use uuid::Uuid; use buzz_core::{CommunityId, StoredEvent}; -fn event_replacement_lock_key( +pub(crate) fn event_replacement_lock_key( community_id: CommunityId, kind: i32, pubkey: &[u8], @@ -172,6 +186,43 @@ pub async fn insert_mentions( Ok(()) } +/// Transaction-aware mention-index projection for a protected event commit. +pub async fn insert_mentions_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + 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))?; + for pubkey in event.tags.iter().filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 + && parts[0] == "p" + && parts[1].len() == 64 + && parts[1] + .chars() + .all(|character| character.is_ascii_hexdigit())) + .then(|| parts[1].to_ascii_lowercase()) + }) { + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) \ + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(event.id.as_bytes().as_slice()) + .bind(created_at) + .bind(channel_id) + .bind(event.kind.as_u16() as i32) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -1933,6 +1984,17 @@ impl Db { push::claim_due_match_batch(&self.pool, limit, lease_until).await } + /// Claim a matcher batch outside exact protected Enforce domains. + pub async fn claim_due_push_match_batch_excluding( + &self, + limit: i64, + lease_until: DateTime, + excluded_communities: &[Uuid], + ) -> Result> { + push::claim_due_match_batch_excluding(&self.pool, limit, lease_until, excluded_communities) + .await + } + /// Load active endpoint-enabled leases eligible for push matching. pub async fn active_push_match_leases( &self, @@ -1967,6 +2029,14 @@ impl Db { push::reap_exhausted_matches(&self.pool).await } + /// Reap matcher jobs outside exact protected Enforce domains. + pub async fn reap_exhausted_push_matches_excluding( + &self, + excluded_communities: &[Uuid], + ) -> Result { + push::reap_exhausted_matches_excluding(&self.pool, excluded_communities).await + } + /// Idempotently enqueue a wake for a matched lease and event. pub async fn enqueue_push_wake( &self, @@ -2320,6 +2390,27 @@ impl Db { channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } + /// Revalidate uncached read access to one channel at an outbound release + /// boundary. + 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, + community_id: CommunityId, + channel_ids: &[Uuid], + pubkey: &[u8], + ) -> Result { + channel::channel_set_read_authorized(&self.pool, community_id, channel_ids, pubkey).await + } + /// Lists channels, optionally filtered by visibility. pub async fn list_channels( &self, @@ -2454,6 +2545,14 @@ impl Db { channel::reap_expired_ephemeral_channels(&self.pool).await } + /// Archive expired ephemeral channels outside protected Enforce domains. + pub async fn reap_expired_ephemeral_channels_excluding( + &self, + excluded_communities: &[Uuid], + ) -> Result> { + channel::reap_expired_ephemeral_channels_excluding(&self.pool, excluded_communities).await + } + /// Query due reminders ready for delivery. pub async fn query_due_reminders( &self, @@ -2463,6 +2562,22 @@ impl Db { event::query_due_reminders(&self.pool, now_secs, batch_limit).await } + /// Query reminders outside protected Enforce domains. + pub async fn query_due_reminders_excluding( + &self, + now_secs: i64, + batch_limit: i64, + excluded_communities: &[Uuid], + ) -> Result> { + event::query_due_reminders_excluding( + &self.pool, + now_secs, + batch_limit, + excluded_communities, + ) + .await + } + /// Atomically claim a due reminder for delivery (cross-pod dedup). pub async fn claim_due_reminder( &self, @@ -4333,6 +4448,16 @@ impl Db { relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await } + /// Delete expired invites outside protected Enforce domains. + pub async fn reap_expired_relay_invites_excluding( + &self, + cutoff: chrono::DateTime, + excluded_communities: &[Uuid], + ) -> Result { + relay_invite::reap_expired_relay_invites_excluding(&self.pool, cutoff, excluded_communities) + .await + } + /// Atomically claims a v2 relay invite. The full redemption (membership /// insert, policy evidence, use_count increment) runs in one PostgreSQL /// transaction with `FOR UPDATE` on the invite row. @@ -4570,6 +4695,16 @@ impl Db { git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await } + /// Return an existing Git reservation's immutable publication origin. + pub async fn repo_publication_origin( + &self, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result> { + git_repo::repo_publication_origin(&self.pool, community_id, repo_id, owner_pubkey).await + } + /// Release a git repo name reservation held by `owner_pubkey` (rollback). /// /// Returns the number of rows removed (0 or 1). See [`git_repo::release_repo_name`]. @@ -6281,6 +6416,61 @@ mod tests { assert_eq!(retry, restored); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_community_lifecycle_is_fail_closed_while_off_remains_legacy() { + let db = setup_db().await; + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let protected_host = format!("protected-lifecycle-{}.example", Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&protected_host, &owner) + .await + .expect("create protected fixture"); + let CreateCommunityWithOwnerResult::Created(protected) = created else { + panic!("expected new protected fixture"); + }; + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(protected.id.as_uuid()) + .execute(&db.pool) + .await + .expect("activate protected marker"); + + assert!(db + .archive_community_owned_by(&protected_host, &owner, "reserved.example") + .await + .is_err()); + assert!(sqlx::query("DELETE FROM communities WHERE id=$1") + .bind(protected.id.as_uuid()) + .execute(&db.pool) + .await + .is_err()); + assert!(db + .lookup_community_by_host(&protected_host) + .await + .expect("protected lookup") + .is_some()); + + let off_host = format!("off-lifecycle-{}.example", Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&off_host, &owner) + .await + .expect("create Off fixture"); + let CreateCommunityWithOwnerResult::Created(off) = created else { + panic!("expected new Off fixture"); + }; + assert!(db + .archive_community_owned_by(&off_host, &owner, "reserved.example") + .await + .expect("Off archive keeps legacy behavior") + .is_some()); + assert!(db + .unarchive_community_owned_by(&off_host, &owner) + .await + .expect("Off restore keeps legacy behavior") + .is_some()); + assert!(!off.id.as_uuid().is_nil()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn create_community_with_owner_enforces_per_owner_limit() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index ab2be54c1d..ca12b751aa 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(), 29); + assert_eq!(migrations.len(), 45); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -617,6 +617,44 @@ mod tests { .as_str() .contains("CREATE INDEX idx_events_tags_gin")); assert!(!migrations[0].sql.as_str().contains("idx_events_tags_gin")); + assert_eq!(migrations[34].version, 35); + assert!(migrations[34] + .sql + .as_str() + .contains("ADD COLUMN publication_origin")); + assert!(!migrations[0].sql.as_str().contains("publication_origin")); + assert_eq!(migrations[39].version, 40); + assert!(migrations[39] + .sql + .as_str() + .contains("protected_domain_marker_delete_guard")); + assert_eq!(migrations[40].version, 41); + assert!(migrations[40].sql.as_str().contains("cleanup_requested_at")); + assert_eq!(migrations[41].version, 42); + assert!(migrations[41] + .sql + .as_str() + .contains("git_policy_update_authority_epoch")); + assert_eq!(migrations[42].version, 43); + let audio_visibility = migrations[42].sql.as_str(); + assert!(audio_visibility.contains("visibility_observed_at")); + assert!(audio_visibility.contains("'reserved', 'active', 'visible', 'aborted', 'finished'")); + assert!(audio_visibility.contains("audio_admission_visibility_transition_guard")); + assert!(audio_visibility + .contains("OLD.state = 'active' AND NEW.state IN ('visible', 'aborted')")); + assert_eq!(migrations[43].version, 44); + let projection_retirement = migrations[43].sql.as_str(); + assert!(projection_retirement.contains("identity_public_projection_heads")); + assert!(projection_retirement.contains("identity_public_projection_retirements")); + assert!(projection_retirement.contains("source_binding_version")); + assert!(!projection_retirement.contains("issuer")); + assert!(!projection_retirement.contains("subject TEXT")); + assert!(!projection_retirement.contains("display_name")); + assert_eq!(migrations[44].version, 45); + let delegated_relationship = migrations[44].sql.as_str(); + assert!(delegated_relationship.contains("delegated_relationship")); + assert!(delegated_relationship + .contains("authorization_invalidation_floors_selector_kind_check")); // NIP-AM (kind 44200) FTS exclusion: additive migration, never folded // into 0001 — folding would change 0001's checksum and break brownfield @@ -983,6 +1021,74 @@ mod tests { "migration 0029 is missing {required}" ); } + assert_eq!(migrations[29].version, 30); + let invalidation = migrations[29].sql.as_str(); + assert!(invalidation.contains("CREATE TABLE authorization_invalidation_domains")); + assert!(invalidation.contains("CREATE TABLE authorization_invalidation_receipts")); + assert!(invalidation.contains("CREATE TABLE authorization_invalidation_floors")); + assert_eq!(migrations[30].version, 31); + let operation_receipts = migrations[30].sql.as_str(); + assert!(operation_receipts.contains("CREATE TABLE authorization_operation_receipts")); + assert!(operation_receipts.contains("request_fingerprint")); + assert!(operation_receipts.contains("result_payload")); + assert!(operation_receipts.contains("authorization_operation_expiry_guard")); + assert_eq!(migrations[31].version, 32); + let protected_publications = migrations[31].sql.as_str(); + assert!(protected_publications.contains("CREATE TABLE git_repo_publications")); + assert!(protected_publications.contains("CREATE TABLE media_publications")); + assert_eq!(migrations[32].version, 33); + let audio_admissions = migrations[32].sql.as_str(); + assert!(audio_admissions.contains("CREATE TABLE audio_session_admissions")); + assert!(audio_admissions.contains("lease_expires_at")); + assert!(audio_admissions.contains("audio_session_admissions_channel_fk")); + assert_eq!(migrations[33].version, 34); + let protected_object_authority = migrations[33].sql.as_str(); + assert!(protected_object_authority.contains("CREATE TABLE protected_object_authority")); + assert!(protected_object_authority.contains("inventory_sha256")); + assert!( + protected_object_authority.contains("state IN ('legacy', 'importing', 'postgresql')") + ); + assert_eq!(migrations[34].version, 35); + assert!(migrations[34] + .sql + .as_str() + .contains("ADD COLUMN publication_origin")); + assert_eq!(migrations[35].version, 36); + let audio_lifecycle = migrations[35].sql.as_str(); + assert!(audio_lifecycle.contains("ADD COLUMN state TEXT")); + assert!(audio_lifecycle.contains("'reserved', 'active', 'aborted', 'finished'")); + assert!(audio_lifecycle.contains("idx_audio_session_admissions_reconcile")); + assert_eq!(migrations[36].version, 37); + assert_eq!(migrations[37].version, 38); + assert_eq!(migrations[38].version, 39); + assert!(migrations[38] + .sql + .as_str() + .contains("protected_community_lifecycle_guard")); + let authority_epochs = migrations[36].sql.as_str(); + assert!(authority_epochs.contains("CREATE TABLE authorization_authority_epochs")); + assert!(authority_epochs.contains("CREATE TABLE client_status_revisions")); + assert!(authority_epochs.contains("advance_authorization_authority_epoch")); + assert!(authority_epochs.contains("pg_trigger_depth() > 1")); + assert!(authority_epochs.contains("ON DELETE CASCADE")); + for protected_table in [ + "identity_bindings", + "identity_principals", + "identity_revoked_keys", + "identity_retired_pairs", + "relay_members", + "channel_members", + "community_bans", + "channels", + "users", + "authorization_invalidation_domains", + "git_repo_publications", + "media_publications", + "protected_object_authority", + "audio_session_admissions", + ] { + assert!(authority_epochs.contains(protected_table)); + } } fn additive_identity_executable_sql(sql: &str) -> String { @@ -1419,7 +1525,15 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(28)); + let latest_version = MIGRATOR + .iter() + .map(|migration| migration.version) + .max() + .expect("embedded migration set is non-empty"); + assert_eq!( + applied_versions(&pool).await.last().copied(), + Some(latest_version) + ); } #[tokio::test] @@ -2596,4 +2710,96 @@ mod tests { "fresh installs must default non-allowlisted kinds to NULL: {search_expression}" ); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authority_triggers_preserve_off_and_deny_unwitnessed_protected_teardown() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + run_migrations(&pool).await.expect("apply all migrations"); + + let community_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(format!( + "authority-trigger-{}.example", + community_id.simple() + )) + .execute(&pool) + .await + .expect("insert legacy community"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')", + ) + .bind(community_id) + .bind("11".repeat(32)) + .execute(&pool) + .await + .expect("legacy membership remains writable"); + let legacy_domains: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read legacy authorization rows"); + assert_eq!(legacy_domains, 0, "Off must not acquire protected state"); + + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(community_id) + .execute(&pool) + .await + .expect("initialize protected domain"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')", + ) + .bind(community_id) + .bind("22".repeat(32)) + .execute(&pool) + .await + .expect("protected membership mutation"); + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read protected generation"); + assert_eq!(generation, 1, "one mutation advances generation once"); + + sqlx::query("DELETE FROM relay_members WHERE community_id=$1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete ordinary community-owned rows first"); + assert!(sqlx::query("DELETE FROM communities WHERE id=$1") + .bind(community_id) + .execute(&pool) + .await + .is_err()); + let retained: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_authority_epochs WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read retained authority state"); + assert_eq!(retained, 1, "denied teardown retains the monotonic floor"); + + assert!(sqlx::query( + "DELETE FROM authorization_invalidation_domains WHERE community_id=$1" + ) + .bind(community_id) + .execute(&pool) + .await + .is_err()); + let marker: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read retained activation marker"); + assert_eq!(marker, 1, "protected activation is a one-way cutover"); + } } 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-db/src/public_projection.rs b/crates/buzz-db/src/public_projection.rs new file mode 100644 index 0000000000..516699e67d --- /dev/null +++ b/crates/buzz-db/src/public_projection.rs @@ -0,0 +1,2592 @@ +//! Durable reconciliation for the optional relay-authored identity projection. +//! +//! O3 lifecycle rows remain authoritative. This module stores only public +//! event coordinates and opaque binding generations; it is neither an +//! operator API nor a durable audit surface. + +use std::{fmt, time::Duration}; + +use buzz_core::{CommunityId, StoredEvent}; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + event::{self, EventQuery}, + identity_binding::{key_lock_coordinate, lock_identity_coordinates_tx}, + Db, DbError, Result, +}; + +const ASSERTION_KIND: i32 = 30382; +const CLAIM_LEASE: Duration = Duration::from_secs(30); + +/// Opaque source generation for one relay-authored public projection. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProjectionBindingOrigin { + binding_id: Uuid, + binding_version: u64, +} + +impl ProjectionBindingOrigin { + /// Stable binding identifier used only for server-side fencing. + pub const fn binding_id(self) -> Uuid { + self.binding_id + } + + /// Positive binding generation used only for server-side fencing. + pub const fn binding_version(self) -> u64 { + self.binding_version + } +} + +impl fmt::Debug for ProjectionBindingOrigin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionBindingOrigin") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .finish() + } +} + +/// Server-only disposition recorded for the current 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 label-free inactive replacement. + Inactive, +} + +impl ProjectionDisposition { + const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + } + } +} + +/// Current server-only ownership metadata for an assertion coordinate. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProjectionHead { + event_id: [u8; 32], + disposition: ProjectionDisposition, + origin: Option, +} + +impl ProjectionHead { + /// Exact signed event installed with this ownership record. + pub const fn event_id(self) -> [u8; 32] { + self.event_id + } + + /// Whether the current projection is active or inactive. + pub const fn disposition(self) -> ProjectionDisposition { + self.disposition + } + + /// Exact binding generation that created the current projection, if known. + pub const fn origin(self) -> Option { + self.origin + } +} + +impl fmt::Debug for ProjectionHead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionHead") + .field("event_id", &"[redacted]") + .field("disposition", &self.disposition) + .field("origin", &"[redacted]") + .finish() + } +} + +fn checked_version(value: i64) -> Result { + u64::try_from(value) + .map_err(|_| DbError::InvalidData("public projection version is invalid".to_owned())) +} + +fn checked_i64(value: u64) -> Result { + i64::try_from(value) + .map_err(|_| DbError::InvalidData("public projection version is invalid".to_owned())) +} + +fn validate_pubkey(value: &[u8]) -> Result<()> { + if value.len() != 32 { + return Err(DbError::InvalidData( + "public projection key must be 32 bytes".to_owned(), + )); + } + Ok(()) +} + +async fn authoritative_binding_for_exact_principal_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + subject: &str, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT binding.binding_id, binding.binding_version + FROM identity_bindings binding + WHERE binding.community_id=$1 + AND binding.issuer=$2 + AND binding.uid=$3 + AND binding.pubkey=$4 + AND binding.binding_state='active' + AND binding.revoked_at IS NULL + AND binding.rotation_completed_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id=binding.community_id + AND denial.issuer=binding.issuer AND denial.subject=binding.uid) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denied_keys denial + WHERE denial.community_id=binding.community_id + AND denial.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_principals principal + WHERE principal.community_id=binding.community_id + AND principal.issuer=binding.issuer AND principal.uid=binding.uid + AND principal.disabled_at IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_revoked_keys revoked + WHERE revoked.community_id=binding.community_id + AND revoked.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_pending_replacements pending + WHERE pending.community_id=binding.community_id + AND pending.issuer=binding.issuer AND pending.subject=binding.uid + AND pending.cleared_at IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_retired_pairs retired + WHERE retired.community_id=binding.community_id + AND retired.issuer=binding.issuer AND retired.subject=binding.uid + AND retired.pubkey=binding.pubkey) + FOR SHARE OF binding + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(subject) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(ProjectionBindingOrigin { + binding_id: row.try_get("binding_id")?, + binding_version: checked_version(row.try_get("binding_version")?)?, + }) + }) + .transpose() +} + +async fn authoritative_binding_for_key_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT binding.binding_id, binding.binding_version + FROM identity_bindings binding + WHERE binding.community_id=$1 + AND binding.pubkey=$2 + AND binding.binding_state='active' + AND binding.revoked_at IS NULL + AND binding.rotation_completed_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id=binding.community_id + AND denial.issuer=binding.issuer AND denial.subject=binding.uid) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denied_keys denial + WHERE denial.community_id=binding.community_id + AND denial.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_principals principal + WHERE principal.community_id=binding.community_id + AND principal.issuer=binding.issuer AND principal.uid=binding.uid + AND principal.disabled_at IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_revoked_keys revoked + WHERE revoked.community_id=binding.community_id + AND revoked.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_pending_replacements pending + WHERE pending.community_id=binding.community_id + AND pending.issuer=binding.issuer AND pending.subject=binding.uid + AND pending.cleared_at IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_retired_pairs retired + WHERE retired.community_id=binding.community_id + AND retired.issuer=binding.issuer AND retired.subject=binding.uid + AND retired.pubkey=binding.pubkey) + FOR SHARE OF binding + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(ProjectionBindingOrigin { + binding_id: row.try_get("binding_id")?, + binding_version: checked_version(row.try_get("binding_version")?)?, + }) + }) + .transpose() +} + +async fn current_projection_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result> { + let subject = hex::encode(subject_pubkey); + Ok(event::query_events_tx( + tx, + &EventQuery { + kinds: Some(vec![ASSERTION_KIND]), + pubkey: Some(relay_pubkey.to_vec()), + d_tag: Some(subject), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community_id) + }, + ) + .await? + .into_iter() + .next()) +} + +async fn projection_by_id_including_deleted_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event_id: &[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 \ + ORDER BY created_at DESC LIMIT 1 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut **tx) + .await?; + row.map(event::row_to_stored_event) + .transpose() + .map(Option::flatten) +} + +async fn projection_head_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT event_id, disposition, source_binding_id, source_binding_version \ + FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(relay_pubkey) + .bind(subject_pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + let event_id: Vec = row.try_get("event_id")?; + let event_id = event_id.try_into().map_err(|_| { + DbError::InvalidData("public projection head event id is invalid".to_owned()) + })?; + let disposition: String = row.try_get("disposition")?; + let disposition = match disposition.as_str() { + "active" => ProjectionDisposition::Active, + "inactive" => ProjectionDisposition::Inactive, + _ => { + return Err(DbError::InvalidData( + "public projection head disposition is invalid".to_owned(), + )) + } + }; + let binding_id: Option = row.try_get("source_binding_id")?; + let binding_version: Option = row.try_get("source_binding_version")?; + let origin = match (binding_id, binding_version) { + (Some(binding_id), Some(binding_version)) => Some(ProjectionBindingOrigin { + binding_id, + binding_version: checked_version(binding_version)?, + }), + (None, None) => None, + _ => { + return Err(DbError::InvalidData( + "public projection head origin is incomplete".to_owned(), + )) + } + }; + Ok(ProjectionHead { + event_id, + disposition, + origin, + }) + }) + .transpose() +} + +async fn upsert_projection_head_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], + event: &Event, + disposition: ProjectionDisposition, + origin: Option, +) -> Result<()> { + let created_at = DateTime::::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or(DbError::InvalidTimestamp(event.created_at.as_secs() as i64))?; + sqlx::query( + r#" + INSERT INTO identity_public_projection_heads + (community_id, relay_pubkey, subject_pubkey, event_id, + event_created_at, disposition, source_binding_id, + source_binding_version, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) + ON CONFLICT (community_id, relay_pubkey, subject_pubkey) DO UPDATE + SET event_id=EXCLUDED.event_id, + event_created_at=EXCLUDED.event_created_at, + disposition=EXCLUDED.disposition, + source_binding_id=EXCLUDED.source_binding_id, + source_binding_version=EXCLUDED.source_binding_version, + updated_at=NOW() + "#, + ) + .bind(community_id.as_uuid()) + .bind(relay_pubkey) + .bind(subject_pubkey) + .bind(event.id.as_bytes().as_slice()) + .bind(created_at) + .bind(disposition.as_str()) + .bind(origin.map(ProjectionBindingOrigin::binding_id)) + .bind( + origin + .map(ProjectionBindingOrigin::binding_version) + .map(checked_i64) + .transpose()?, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn validate_event_coordinate( + event: &Event, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result { + let subject = hex::encode(subject_pubkey); + 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() + }; + if event.kind.as_u16() as i32 != ASSERTION_KIND + || event.pubkey.as_bytes() != relay_pubkey + || exact_tag_count("d", &subject) != 1 + || exact_tag_count("p", &subject) != 1 + || exact_tag_count("verified", "relay") != 1 + || !event.verify_id() + || !event.verify_signature() + { + return Err(DbError::InvalidData( + "public projection event coordinate is invalid".to_owned(), + )); + } + Ok(subject) +} + +fn validate_event_disposition(event: &Event, disposition: ProjectionDisposition) -> Result<()> { + 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() + }; + let expiration = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "expiration" + }) + .map(|tag| tag.as_slice()[1].parse::()) + .collect::, _>>() + .map_err(|_| DbError::InvalidData("public projection expiration is invalid".to_owned()))?; + let display_names = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "display_name" + }) + .map(|tag| tag.as_slice()[1].as_str()) + .collect::>(); + let valid = event.content.is_empty() + && match disposition { + ProjectionDisposition::Active => { + event.tags.len() == 6 + && exact_tag_count("active", "true") == 1 + && exact_tag_count("active", "false") == 0 + && expiration.len() == 1 + && expiration[0] > 0 + && display_names.len() == 1 + && !display_names[0].is_empty() + } + ProjectionDisposition::Inactive => { + event.tags.len() == 5 + && exact_tag_count("active", "false") == 1 + && exact_tag_count("active", "true") == 0 + && expiration.as_slice() == [0] + && display_names.is_empty() + } + }; + if !valid { + return Err(DbError::InvalidData( + "public projection disposition is invalid".to_owned(), + )); + } + Ok(()) +} + +fn canonical_later_projection_is_proven( + current: &StoredEvent, + head: ProjectionHead, + expected: &StoredEvent, + source: ProjectionBindingOrigin, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result { + validate_event_coordinate(¤t.event, relay_pubkey, subject_pubkey)?; + validate_event_disposition(¤t.event, head.disposition)?; + validate_event_coordinate(&expected.event, relay_pubkey, subject_pubkey)?; + validate_event_disposition(&expected.event, ProjectionDisposition::Inactive)?; + + let head_matches_current = head.event_id.as_slice() == current.event.id.as_bytes().as_slice(); + let current_is_later = current.event.created_at > expected.event.created_at + || (current.event.created_at == expected.event.created_at + && current.event.id.as_bytes().as_slice() < expected.event.id.as_bytes().as_slice()); + let later_owner_is_proven = head.origin.is_some_and(|origin| { + origin.binding_id != source.binding_id || origin.binding_version > source.binding_version + }); + + Ok(head_matches_current && current_is_later && later_owner_is_proven) +} + +/// Transaction-owned active publication permit. +pub struct ActivePublicProjectionPermit { + tx: Transaction<'static, Postgres>, + community_id: CommunityId, + relay_pubkey: Vec, + subject_pubkey: Vec, + origin: ProjectionBindingOrigin, + current: Option, +} + +impl fmt::Debug for ActivePublicProjectionPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActivePublicProjectionPermit") + .field("community_id", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("subject_pubkey", &"[redacted]") + .field("origin", &"[redacted]") + .finish_non_exhaustive() + } +} + +impl ActivePublicProjectionPermit { + /// Current stored projection at the locked coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + self.current.as_ref() + } + + /// Exact active binding generation retained through commit. + pub const fn origin(&self) -> ProjectionBindingOrigin { + self.origin + } + + /// Atomically replace or accept the candidate and record private ownership. + pub async fn commit( + mut self, + event: &Event, + disposition: ProjectionDisposition, + ) -> Result { + let subject = validate_event_coordinate(event, &self.relay_pubkey, &self.subject_pubkey)?; + validate_event_disposition(event, disposition)?; + let (candidate, inserted) = event::replace_parameterized_event_tx( + &mut self.tx, + self.community_id, + event, + &subject, + None, + ) + .await?; + let stored = if inserted { + candidate + } else { + let current = current_projection_tx( + &mut self.tx, + self.community_id, + &self.relay_pubkey, + &self.subject_pubkey, + ) + .await? + .ok_or_else(|| { + DbError::InvalidData("public projection replacement disappeared".to_owned()) + })?; + if current.event.id != event.id { + return Err(DbError::InvalidData( + "public projection candidate lost ordering".to_owned(), + )); + } + current + }; + upsert_projection_head_tx( + &mut self.tx, + self.community_id, + &self.relay_pubkey, + &self.subject_pubkey, + &stored.event, + disposition, + Some(self.origin), + ) + .await?; + self.tx.commit().await?; + Ok(stored) + } +} + +/// Begin active publication while retaining exact binding authority to commit. +pub async fn begin_active_public_projection( + db: &Db, + community_id: CommunityId, + relay_pubkey: &[u8], + issuer: &str, + subject: &str, + subject_pubkey: &[u8], +) -> Result> { + validate_pubkey(relay_pubkey)?; + validate_pubkey(subject_pubkey)?; + if issuer.is_empty() || subject.is_empty() { + return Err(DbError::InvalidData( + "public projection principal is invalid".to_owned(), + )); + } + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(community_id, subject_pubkey)], + ) + .await?; + let Some(origin) = authoritative_binding_for_exact_principal_tx( + &mut tx, + community_id, + issuer, + subject, + subject_pubkey, + ) + .await? + else { + tx.rollback().await?; + return Ok(None); + }; + let current = + current_projection_tx(&mut tx, community_id, relay_pubkey, subject_pubkey).await?; + Ok(Some(ActivePublicProjectionPermit { + tx, + community_id, + relay_pubkey: relay_pubkey.to_vec(), + subject_pubkey: subject_pubkey.to_vec(), + origin, + current, + })) +} + +/// Materialize committed O3 revoke/rotate operations as retryable O4 work. +pub async fn materialize_public_projection_retirements( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(0); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let result = sqlx::query( + r#" + INSERT INTO identity_public_projection_retirements + (community_id, operation_id, relay_pubkey, old_pubkey, + source_binding_id, source_binding_version, operation_kind) + SELECT operation.community_id, operation.operation_id, $2, + operation.pubkey, retired.binding_id, + retired.binding_version - 1, operation.operation_kind + FROM identity_lifecycle_operations operation + LEFT JOIN LATERAL ( + SELECT history.binding_id, history.binding_version + FROM identity_binding_history history + WHERE history.community_id=operation.community_id + AND history.operation_id=operation.operation_id + AND history.binding_id IS NOT DISTINCT FROM operation.binding_id + AND history.pubkey=operation.pubkey + AND history.binding_state IN ('revoked', 'rotated') + AND history.binding_version > 1 + ORDER BY history.recorded_at DESC, history.history_id + LIMIT 1 + ) retired ON TRUE + WHERE operation.community_id = ANY($1) + AND operation.operation_kind IN ('revoke_key', 'rotate') + AND operation.pubkey IS NOT NULL + ON CONFLICT (community_id, operation_id, relay_pubkey) DO NOTHING + "#, + ) + .bind(domain_ids) + .bind(relay_pubkey) + .execute(&db.pool) + .await?; + Ok(result.rows_affected()) +} + +/// Retryable retirement work claimed by one relay replica. +#[derive(Clone, PartialEq, Eq)] +pub struct ProjectionRetirementClaim { + community_id: CommunityId, + operation_id: Uuid, + relay_pubkey: Vec, + old_pubkey: Vec, + source_origin: Option, + claim_token: Uuid, +} + +impl ProjectionRetirementClaim { + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Public subject key whose old assertion may need retirement. + pub fn old_pubkey(&self) -> &[u8] { + &self.old_pubkey + } + + /// Exact retired source generation, when the lifecycle transition had one. + pub const fn source_origin(&self) -> Option { + self.source_origin + } +} + +impl fmt::Debug for ProjectionRetirementClaim { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionRetirementClaim") + .field("community_id", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("old_pubkey", &"[redacted]") + .field("source_origin", &"[redacted]") + .finish() + } +} + +async fn claim_next( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], + phase: &str, +) -> Result> { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(None); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let lease_seconds = i64::try_from(CLAIM_LEASE.as_secs()) + .map_err(|_| DbError::InvalidData("projection claim lease is invalid".to_owned()))?; + let row = sqlx::query( + r#" + WITH candidate AS ( + SELECT community_id, operation_id, relay_pubkey + FROM identity_public_projection_retirements + WHERE community_id=ANY($1) AND relay_pubkey=$2 AND phase=$3 + AND next_attempt_at <= NOW() + AND (claim_token IS NULL OR lease_until <= NOW()) + ORDER BY next_attempt_at, community_id, operation_id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE identity_public_projection_retirements work + SET claim_token=gen_random_uuid(), + lease_until=NOW() + ($4::DOUBLE PRECISION * INTERVAL '1 second'), + attempts=attempts+1, + updated_at=NOW() + FROM candidate + WHERE work.community_id=candidate.community_id + AND work.operation_id=candidate.operation_id + AND work.relay_pubkey=candidate.relay_pubkey + RETURNING work.community_id, work.operation_id, work.relay_pubkey, + work.old_pubkey, work.source_binding_id, + work.source_binding_version, work.claim_token + "#, + ) + .bind(domain_ids) + .bind(relay_pubkey) + .bind(phase) + .bind(lease_seconds) + .fetch_optional(&db.pool) + .await?; + row.map(|row| { + let source_binding_id: Option = row.try_get("source_binding_id")?; + let source_binding_version: Option = row.try_get("source_binding_version")?; + let source_origin = match (source_binding_id, source_binding_version) { + (Some(binding_id), Some(binding_version)) => Some(ProjectionBindingOrigin { + binding_id, + binding_version: checked_version(binding_version)?, + }), + (None, None) => None, + _ => { + return Err(DbError::InvalidData( + "projection retirement source is incomplete".to_owned(), + )) + } + }; + Ok(ProjectionRetirementClaim { + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + operation_id: row.try_get("operation_id")?, + relay_pubkey: row.try_get("relay_pubkey")?, + old_pubkey: row.try_get("old_pubkey")?, + source_origin, + claim_token: row.try_get("claim_token")?, + }) + }) + .transpose() +} + +/// Claim one ready public-projection retirement operation. +pub async fn claim_public_projection_retirement( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result> { + claim_next(db, domains, relay_pubkey, "projection").await +} + +/// Transaction-owned view of one claimed retirement. +pub struct ProjectionRetirementPermit { + tx: Transaction<'static, Postgres>, + claim: ProjectionRetirementClaim, + current: Option, + head: Option, + active_origin: Option, +} + +impl fmt::Debug for ProjectionRetirementPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionRetirementPermit") + .field("claim", &self.claim) + .field("current", &self.current.as_ref().map(|_| "[event]")) + .field("head", &self.head) + .field("active_origin", &"[redacted]") + .finish_non_exhaustive() + } +} + +impl ProjectionRetirementPermit { + /// Public subject key selected by the committed lifecycle operation. + pub fn old_pubkey(&self) -> &[u8] { + &self.claim.old_pubkey + } + + /// Current event at the locked assertion coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + self.current.as_ref() + } + + /// Current private projection ownership metadata. + pub const fn head(&self) -> Option { + self.head + } + + /// Current authoritative binding for the key, if it has been reused. + pub const fn active_origin(&self) -> Option { + self.active_origin + } + + /// Retired binding generation carried by the durable lifecycle work. + pub const fn source_origin(&self) -> Option { + self.claim.source_origin + } + + fn head_for_current(&self, disposition: ProjectionDisposition) -> Result { + let current = self.current.as_ref().ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, disposition)?; + let head = self.head.ok_or_else(|| { + DbError::InvalidData("public projection ownership is unavailable".to_owned()) + })?; + if head.event_id != *current.event.id.as_bytes() || head.disposition != disposition { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + Ok(head) + } + + fn current_can_be_retired_by_source(&self) -> Result { + let (current, head) = match (self.current.as_ref(), self.head) { + (None, None) => return Ok(true), + (None, Some(_)) => { + return Err(DbError::InvalidData( + "public projection ownership exists without an event".to_owned(), + )) + } + (Some(_), None) => return Ok(true), + (Some(current), Some(head)) => (current, head), + }; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, head.disposition)?; + if head.event_id != *current.event.id.as_bytes() { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + Ok(match (self.claim.source_origin, head.origin) { + (_, None) => true, + (None, Some(_)) => false, + (Some(source), Some(origin)) if source.binding_id == origin.binding_id => { + origin.binding_version <= source.binding_version + } + (Some(_), Some(_)) => false, + }) + } + + async fn finish_terminal(mut self, phase: &str, outcome: &str) -> Result<()> { + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase=$5, outcome=$6, claim_token=NULL, lease_until=NULL, \ + completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .bind(phase) + .bind(outcome) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(()) + } + + /// Complete a job whose assertion coordinate is empty. + pub async fn finish_no_projection(self) -> Result<()> { + if self.current.is_some() || self.head.is_some() { + return Err(DbError::InvalidData( + "public projection coordinate is not empty".to_owned(), + )); + } + self.finish_terminal("completed", "no_projection").await + } + + /// Preserve a newer legitimate binding and finish the stale job. + pub async fn finish_superseded(self, current: &Event) -> Result<()> { + let origin = self.active_origin.ok_or_else(|| { + DbError::InvalidData("projection supersession lacks active binding".to_owned()) + })?; + validate_event_coordinate(current, &self.claim.relay_pubkey, &self.claim.old_pubkey)?; + validate_event_disposition(current, ProjectionDisposition::Active)?; + let current_id = self + .current + .as_ref() + .map(|stored| stored.event.id) + .ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + let head = self.head_for_current(ProjectionDisposition::Active)?; + if current.id != current_id + || head.origin != Some(origin) + || self.claim.source_origin == Some(origin) + { + return Err(DbError::InvalidData( + "active binding does not own the current public projection".to_owned(), + )); + } + self.finish_terminal("superseded", "newer_binding").await + } + + /// Preserve a projection owned by a later binding generation. + pub async fn finish_newer_projection(self) -> Result<()> { + let head = self.head_for_current(ProjectionDisposition::Active)?; + let origin = head.origin.ok_or_else(|| { + DbError::InvalidData("newer public projection lacks an owner".to_owned()) + })?; + let is_newer = match self.claim.source_origin { + None => true, + Some(source) if source.binding_id == origin.binding_id => { + origin.binding_version > source.binding_version + } + Some(source) => source != origin, + }; + if !is_newer { + return Err(DbError::InvalidData( + "public projection is not owned by a later generation".to_owned(), + )); + } + self.finish_terminal("superseded", "newer_projection").await + } + + /// Finish a stale/replayed retirement when the exact coordinate is already + /// the canonical inactive projection. + /// + /// This is a metadata-only convergence path: it preserves the event and + /// ownership head byte-for-byte and never relabels an old assertion to the + /// replayed job's source generation. + pub async fn finish_existing_inactive(self) -> Result<()> { + let current = self.current.as_ref().ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, ProjectionDisposition::Inactive)?; + if let Some(head) = self.head { + if head.event_id != *current.event.id.as_bytes() + || head.disposition != ProjectionDisposition::Inactive + { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + } + self.finish_terminal("completed", "already_inactive").await + } + + /// Atomically install/accept the canonical inactive event and queue delivery. + pub async fn finish_inactive(mut self, inactive: &Event) -> Result { + if !self.current_can_be_retired_by_source()? { + return Err(DbError::InvalidData( + "public projection belongs to a later binding generation".to_owned(), + )); + } + let subject = + validate_event_coordinate(inactive, &self.claim.relay_pubkey, &self.claim.old_pubkey)?; + validate_event_disposition(inactive, ProjectionDisposition::Inactive)?; + let (candidate, inserted) = event::replace_parameterized_event_tx( + &mut self.tx, + self.claim.community_id, + inactive, + &subject, + None, + ) + .await?; + let stored = if inserted { + candidate + } else { + let current = current_projection_tx( + &mut self.tx, + self.claim.community_id, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + ) + .await? + .ok_or_else(|| { + DbError::InvalidData("inactive projection replacement disappeared".to_owned()) + })?; + if current.event.id != inactive.id { + return Err(DbError::InvalidData( + "inactive projection candidate lost ordering".to_owned(), + )); + } + current + }; + upsert_projection_head_tx( + &mut self.tx, + self.claim.community_id, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + &stored.event, + ProjectionDisposition::Inactive, + self.claim.source_origin, + ) + .await?; + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='delivery', outcome=$5, event_id=$6, claim_token=NULL, \ + lease_until=NULL, next_attempt_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .bind(if inserted { + "replaced_inactive" + } else { + "already_inactive" + }) + .bind(stored.event.id.as_bytes().as_slice()) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(stored) + } + + /// Release retryable work with bounded backoff and no authority change. + pub async fn defer(mut self) -> Result<()> { + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET claim_token=NULL, lease_until=NULL, \ + next_attempt_at=NOW() + (LEAST(60, GREATEST(1, attempts))::DOUBLE PRECISION * INTERVAL '1 second'), \ + updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection'", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + self.tx.commit().await?; + Ok(()) + } +} + +/// Revalidate and lock one claimed retirement through its event commit boundary. +pub async fn begin_public_projection_retirement( + db: &Db, + claim: ProjectionRetirementClaim, +) -> Result { + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(claim.community_id, &claim.old_pubkey)], + ) + .await?; + let claimed = sqlx::query( + "SELECT 1 FROM identity_public_projection_retirements \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW() FOR UPDATE", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !claimed { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + let active_origin = + authoritative_binding_for_key_tx(&mut tx, claim.community_id, &claim.old_pubkey).await?; + let head = projection_head_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let current = current_projection_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + Ok(ProjectionRetirementPermit { + tx, + claim, + current, + head, + active_origin, + }) +} + +/// Delivery work retained until Redis and local fan-out have both been attempted. +pub struct ProjectionDeliveryPermit { + tx: Transaction<'static, Postgres>, + claim: ProjectionRetirementClaim, + stored: StoredEvent, +} + +impl fmt::Debug for ProjectionDeliveryPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionDeliveryPermit") + .field("claim", &self.claim) + .field("stored", &"[event]") + .finish_non_exhaustive() + } +} + +impl ProjectionDeliveryPermit { + /// Exact current inactive event retained under the identity-key lock. + pub const fn stored(&self) -> &StoredEvent { + &self.stored + } + + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + self.claim.community_id + } + + /// Mark delivery complete while the exact projection head remains locked. + pub async fn complete(mut self) -> Result<()> { + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='completed', claim_token=NULL, lease_until=NULL, \ + completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection delivery claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(()) + } + + /// Release delivery for bounded retry without changing the event head. + pub async fn defer(mut self) -> Result<()> { + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET claim_token=NULL, lease_until=NULL, \ + next_attempt_at=NOW() + (LEAST(60, GREATEST(1, attempts))::DOUBLE PRECISION * INTERVAL '1 second'), \ + updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery'", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + self.tx.commit().await?; + Ok(()) + } +} + +/// Claim and lock one pending inactive-event delivery. +pub async fn begin_public_projection_delivery( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result> { + let Some(claim) = claim_next(db, domains, relay_pubkey, "delivery").await? else { + return Ok(None); + }; + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(claim.community_id, &claim.old_pubkey)], + ) + .await?; + let row = sqlx::query( + "SELECT event_id FROM identity_public_projection_retirements \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW() FOR UPDATE", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("public projection delivery claim expired".to_owned()))?; + let expected_id: Vec = row.try_get("event_id")?; + let current = current_projection_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let head = projection_head_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let head_matches_inactive = head.is_some_and(|head| { + head.event_id.as_slice() == expected_id.as_slice() + && head.disposition == ProjectionDisposition::Inactive + }); + let Some(stored) = current + .as_ref() + .filter(|stored| { + stored.event.id.as_bytes().as_slice() == expected_id.as_slice() && head_matches_inactive + }) + .cloned() + else { + let expected = + projection_by_id_including_deleted_tx(&mut tx, claim.community_id, &expected_id) + .await?; + let later_projection_is_proven = match ( + current.as_ref(), + head, + expected.as_ref(), + claim.source_origin, + ) { + (Some(current), Some(head), Some(expected), Some(source)) => { + canonical_later_projection_is_proven( + current, + head, + expected, + source, + &claim.relay_pubkey, + &claim.old_pubkey, + )? + } + _ => false, + }; + if !later_projection_is_proven { + return Err(DbError::InvalidData( + "public projection delivery lost its canonical ownership proof".to_owned(), + )); + } + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='superseded', outcome='newer_projection', claim_token=NULL, \ + lease_until=NULL, completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW()", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .execute(&mut *tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection delivery claim expired".to_owned(), + )); + } + tx.commit().await?; + return Ok(None); + }; + validate_event_coordinate(&stored.event, &claim.relay_pubkey, &claim.old_pubkey)?; + validate_event_disposition(&stored.event, ProjectionDisposition::Inactive)?; + Ok(Some(ProjectionDeliveryPermit { tx, claim, stored })) +} + +/// Count unfinished work for a relay author and exact domain set. +pub async fn unfinished_public_projection_retirements( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(0); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_public_projection_retirements \ + WHERE community_id=ANY($1) AND relay_pubkey=$2 \ + AND phase IN ('projection', 'delivery')", + ) + .bind(domain_ids) + .bind(relay_pubkey) + .fetch_one(&db.pool) + .await?; + u64::try_from(count) + .map_err(|_| DbError::InvalidData("projection retirement count is invalid".to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity_binding::{ + resolve_identity_binding, BindingProvenance, EnrollmentMode, ResolveBindingInput, + ResolveBindingResult, + }; + use crate::identity_lifecycle::{ + revoke_identity_key, rotate_identity_binding, IdentityPrincipal, LifecycleContext, + VerifiedReplacementKey, + }; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const ISSUER: &str = "https://idp.example"; + + 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"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(id) + .bind(format!("public-projection-{}.example", id.simple())) + .execute(&pool) + .await + .expect("insert community"); + (Db::from_pool(pool), CommunityId::from_uuid(id)) + } + + fn assertion(keys: &Keys, subject: nostr::PublicKey, active: bool, at: u64) -> Event { + let subject = subject.to_hex(); + let active_value = if active { "true" } else { "false" }; + let expiration = if active { "500" } else { "0" }; + let mut tags = vec![ + Tag::parse(["d", subject.as_str()]).expect("d tag"), + Tag::parse(["p", subject.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", active_value]).expect("active tag"), + Tag::parse(["expiration", expiration]).expect("expiration tag"), + ]; + if active { + tags.push(Tag::parse(["display_name", "Approved Label"]).expect("display tag")); + } + EventBuilder::new(Kind::Custom(ASSERTION_KIND as u16), "") + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(keys) + .expect("sign assertion") + } + + fn stored(event: Event) -> StoredEvent { + StoredEvent::with_received_at(event, Utc::now(), None, true) + } + + #[test] + fn delivery_supersession_requires_a_canonical_later_projection_and_owner() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let source = ProjectionBindingOrigin { + binding_id: Uuid::new_v4(), + binding_version: 7, + }; + let later = ProjectionBindingOrigin { + binding_id: source.binding_id, + binding_version: 8, + }; + let expected = stored(assertion(&relay, subject, false, 100)); + let current = stored(assertion(&relay, subject, true, 101)); + let head = ProjectionHead { + event_id: *current.event.id.as_bytes(), + disposition: ProjectionDisposition::Active, + origin: Some(later), + }; + + assert!(canonical_later_projection_is_proven( + ¤t, + head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("canonical proof")); + + let stale_head = ProjectionHead { + event_id: *expected.event.id.as_bytes(), + ..head + }; + assert!(!canonical_later_projection_is_proven( + ¤t, + stale_head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("stale head is a denied proof")); + + let unreplaced_head = ProjectionHead { + origin: Some(source), + ..head + }; + assert!(!canonical_later_projection_is_proven( + ¤t, + unreplaced_head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("same owner generation is a denied proof")); + + let noncanonical_expected = stored(assertion(&relay, subject, true, 100)); + assert!(canonical_later_projection_is_proven( + ¤t, + head, + &noncanonical_expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .is_err()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delivery_converges_only_after_a_later_projection_owns_the_coordinate() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let source = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "delivery-race-subject", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected binding result: {other:?}"), + }; + let active = assertion(&relay, subject, true, 100); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "delivery-race-subject", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit active projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "delivery race", + }, + subject.as_bytes(), + ) + .await + .expect("commit revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize retirement"); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim retirement") + .expect("retirement exists"); + let expected = assertion(&relay, subject, false, 101); + begin_public_projection_retirement(&db, claim) + .await + .expect("begin retirement") + .finish_inactive(&expected) + .await + .expect("commit inactive projection"); + + let later = assertion(&relay, subject, true, 102); + let later_origin = ProjectionBindingOrigin { + binding_id: source.binding_id, + binding_version: source.binding_version + 1, + }; + let mut tx = db.pool.begin().await.expect("begin replacement"); + let subject_hex = subject.to_hex(); + let (_, inserted) = + event::replace_parameterized_event_tx(&mut tx, community, &later, &subject_hex, None) + .await + .expect("replace inactive projection"); + assert!(inserted, "later projection must win canonical ordering"); + upsert_projection_head_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + subject.as_bytes(), + &later, + ProjectionDisposition::Active, + Some(later_origin), + ) + .await + .expect("install later projection ownership"); + tx.commit().await.expect("commit later projection"); + + assert!( + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes(),) + .await + .expect("converge stale delivery") + .is_none(), + "a fully proven later projection must terminalize stale delivery" + ); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count unfinished work"), + 0 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn committed_revoke_materializes_retries_and_completes_exactly_once() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + let subject_bytes = subject.to_bytes(); + let resolved = resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "subject-one", + pubkey: subject_bytes.as_slice(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll binding"); + let expected_origin = match resolved { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected binding result: {other:?}"), + }; + assert_eq!(expected_origin.binding_version(), 1); + + let active = assertion(&relay, subject, true, 100); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "subject-one", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit active projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "test revocation", + }, + subject.as_bytes(), + ) + .await + .expect("commit revocation"); + + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("materialize work"), + 1 + ); + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("duplicate discovery is idempotent"), + 0 + ); + let domains = [community]; + let relay_pubkey = relay.public_key().to_bytes(); + let (first_replica, second_replica) = tokio::join!( + claim_public_projection_retirement(&db, &domains, relay_pubkey.as_slice()), + claim_public_projection_retirement(&db, &domains, relay_pubkey.as_slice()), + ); + let mut claims = [ + first_replica.expect("first replica claim"), + second_replica.expect("second replica claim"), + ] + .into_iter() + .flatten() + .collect::>(); + assert_eq!(claims.len(), 1, "only one replica may own the claim"); + let stale_claim = claims.pop().expect("one claim"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET lease_until=NOW() - INTERVAL '1 second' \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .bind(stale_claim.claim_token) + .execute(&db.pool) + .await + .expect("simulate crashed claim owner"); + assert!( + begin_public_projection_retirement(&db, stale_claim) + .await + .is_err(), + "an expired owner must not mutate the projection" + ); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("reclaim crashed work") + .expect("reclaimable work exists"); + assert_eq!(claim.source_origin(), Some(expected_origin)); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin retirement"); + assert_eq!(permit.active_origin(), None); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(expected_origin) + ); + let inactive = assertion(&relay, subject, false, 101); + permit + .finish_inactive(&inactive) + .await + .expect("commit inactive projection"); + + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim delivery") + .expect("delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.defer().await.expect("defer failed delivery"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET next_attempt_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND phase='delivery'", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .execute(&db.pool) + .await + .expect("make deferred delivery ready"); + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("retry delivery") + .expect("deferred delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.complete().await.expect("complete delivery"); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count unfinished"), + 0 + ); + assert!(claim_public_projection_retirement( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("replay claim") + .is_none()); + assert!( + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "subject-one", + subject.as_bytes(), + ) + .await + .expect("revalidate revoked principal") + .is_none(), + "committed revocation must prevent assertion reactivation" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn committed_rotation_retires_only_the_old_projection_generation() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let old_keys = Keys::generate(); + let new_keys = Keys::generate(); + let old_key = old_keys.public_key(); + let new_key = new_keys.public_key(); + let old_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "rotated-subject", + pubkey: old_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll rotation source") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected rotation enrollment: {other:?}"), + }; + let old_active = assertion(&relay, old_key, true, 200); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "rotated-subject", + old_key.as_bytes(), + ) + .await + .expect("begin old projection") + .expect("old binding is active") + .commit(&old_active, ProjectionDisposition::Active) + .await + .expect("commit old projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "test rotation", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "rotated-subject", + }, + old_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + new_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("commit rotation"); + + let new_active = assertion(&relay, new_key, true, 201); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "rotated-subject", + new_key.as_bytes(), + ) + .await + .expect("begin replacement projection") + .expect("replacement binding is active") + .commit(&new_active, ProjectionDisposition::Active) + .await + .expect("commit replacement projection"); + + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("materialize rotation"), + 1 + ); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim rotation") + .expect("rotation work exists"); + assert_eq!(claim.source_origin(), Some(old_origin)); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin old projection retirement"); + assert_eq!(permit.active_origin(), None); + let old_inactive = assertion(&relay, old_key, false, 202); + permit + .finish_inactive(&old_inactive) + .await + .expect("retire old projection"); + + let mut tx = db.pool.begin().await.expect("inspect projections"); + let current_old = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + old_key.as_bytes(), + ) + .await + .expect("read old projection") + .expect("old projection exists"); + let current_new = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + new_key.as_bytes(), + ) + .await + .expect("read replacement projection") + .expect("replacement projection exists"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(current_old.event.id, old_inactive.id); + assert_eq!(current_new.event.id, new_active.id); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn active_key_reuse_without_republication_cannot_claim_the_old_assertion() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let reused_keys = Keys::generate(); + let replacement_keys = Keys::generate(); + let reused_key = reused_keys.public_key(); + let replacement_key = replacement_keys.public_key(); + let original_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "original-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll original binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected original enrollment: {other:?}"), + }; + let original = assertion(&relay, reused_key, true, 250); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "original-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin original projection") + .expect("original binding is active") + .commit(&original, ProjectionDisposition::Active) + .await + .expect("publish original projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "free key for unpublished reuse", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "original-principal", + }, + reused_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + replacement_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("rotate original binding"); + let reused_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: "https://replacement-idp.example", + subject: "replacement-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("reuse key before publishing replacement") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected key reuse: {other:?}"), + }; + assert_ne!(reused_origin, original_origin); + + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize original retirement"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim original retirement") + .expect("original retirement exists"), + ) + .await + .expect("begin original retirement"); + assert_eq!(permit.source_origin(), Some(original_origin)); + assert_eq!(permit.active_origin(), Some(reused_origin)); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(original_origin), + "B has not published and must not own A's assertion" + ); + let current = permit + .current_projection() + .expect("original assertion remains current") + .event + .clone(); + assert!( + permit.finish_superseded(¤t).await.is_err(), + "active binding existence alone cannot transfer projection ownership" + ); + + let mut tx = db.pool.begin().await.expect("inspect unchanged projection"); + let stored = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read current projection") + .expect("current projection remains"); + let head = projection_head_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read projection head") + .expect("projection head remains"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(stored.event.id, original.id); + assert_eq!(head.event_id(), *original.id.as_bytes()); + assert_eq!(head.origin(), Some(original_origin)); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count retryable work"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn binding_version_advance_without_republication_is_not_a_newer_projection() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + let first_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "strengthened-principal", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + }, + ) + .await + .expect("enroll tofu binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected tofu enrollment: {other:?}"), + }; + let original = assertion(&relay, subject, true, 275); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "strengthened-principal", + subject.as_bytes(), + ) + .await + .expect("begin original projection") + .expect("tofu binding is active") + .commit(&original, ProjectionDisposition::Active) + .await + .expect("publish version-one projection"); + let strengthened_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "strengthened-principal", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("strengthen binding without republishing") + { + ResolveBindingResult::Existing(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected strengthening result: {other:?}"), + }; + assert_eq!(strengthened_origin.binding_id(), first_origin.binding_id()); + assert!(strengthened_origin.binding_version() > first_origin.binding_version()); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "revoke strengthened binding", + }, + subject.as_bytes(), + ) + .await + .expect("commit strengthened revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize strengthened retirement"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim strengthened retirement") + .expect("strengthened retirement exists"), + ) + .await + .expect("begin strengthened retirement"); + assert_eq!(permit.source_origin(), Some(strengthened_origin)); + assert_eq!(permit.active_origin(), None); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(first_origin), + "the projection still belongs to version one" + ); + assert!( + permit.finish_newer_projection().await.is_err(), + "an older head cannot terminalize a later-generation retirement" + ); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count retryable strengthened work"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_rotation_work_cannot_retire_a_reused_key_projection() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let reused_keys = Keys::generate(); + let replacement_keys = Keys::generate(); + let reused_key = reused_keys.public_key(); + let replacement_key = replacement_keys.public_key(); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "first-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll first principal"); + let first = assertion(&relay, reused_key, true, 300); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "first-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin first projection") + .expect("first binding is active") + .commit(&first, ProjectionDisposition::Active) + .await + .expect("commit first projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "free key for reuse test", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "first-principal", + }, + reused_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + replacement_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("rotate first principal"); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: "https://second-idp.example", + subject: "second-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("reuse key for independent principal"); + let second = assertion(&relay, reused_key, true, 301); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + "https://second-idp.example", + "second-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin reused projection") + .expect("reused binding is active") + .commit(&second, ProjectionDisposition::Active) + .await + .expect("commit reused projection"); + + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize stale rotation"); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim stale rotation") + .expect("stale rotation exists"); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin stale rotation"); + assert_ne!(permit.active_origin(), permit.source_origin()); + let current = permit + .current_projection() + .expect("reused projection exists") + .event + .clone(); + permit + .finish_superseded(¤t) + .await + .expect("preserve reused projection"); + + let mut tx = db.pool.begin().await.expect("inspect reused projection"); + let stored = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read reused projection") + .expect("reused projection remains"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(stored.event.id, second.id); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count stale work"), + 0 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn populated_upgrade_retires_an_assertion_without_private_head_metadata() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "legacy-projection-subject", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll legacy projection subject"); + let active = assertion(&relay, subject, true, 400); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "legacy-projection-subject", + subject.as_bytes(), + ) + .await + .expect("begin legacy projection") + .expect("legacy binding is active") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit legacy projection"); + sqlx::query( + "DELETE FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .execute(&db.pool) + .await + .expect("simulate pre-migration projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "retire populated upgrade projection", + }, + subject.as_bytes(), + ) + .await + .expect("commit populated-upgrade revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize populated-upgrade work"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim populated-upgrade work") + .expect("populated-upgrade work exists"), + ) + .await + .expect("begin populated-upgrade retirement"); + assert_eq!(permit.head(), None); + let inactive = assertion(&relay, subject, false, 401); + permit + .finish_inactive(&inactive) + .await + .expect("retire populated-upgrade projection"); + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim populated-upgrade delivery") + .expect("populated-upgrade delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.complete().await.expect("complete delivery"); + + // A crash-restored/pre-head replica can rediscover already-inactive + // work without private ownership metadata. Convergence must not + // relabel that public event to the replayed job's source generation. + sqlx::query( + "DELETE FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .execute(&db.pool) + .await + .expect("remove restored private head metadata"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET source_binding_id=NULL, source_binding_version=NULL, \ + phase='projection', outcome=NULL, event_id=NULL, \ + completed_at=NULL, next_attempt_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .execute(&db.pool) + .await + .expect("simulate source-less restored retry"); + let replay = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim restored retry") + .expect("restored retry exists"), + ) + .await + .expect("begin restored retry"); + assert_eq!(replay.head(), None); + assert_eq!( + replay + .current_projection() + .expect("inactive projection remains") + .event + .id, + inactive.id + ); + replay + .finish_existing_inactive() + .await + .expect("source-less inactive retry converges"); + let head_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .fetch_one(&db.pool) + .await + .expect("count private heads after convergence"); + assert_eq!(head_count, 0, "replay must not manufacture ownership"); + } + + #[test] + fn public_projection_disposition_is_canonical_and_label_free_when_inactive() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let active = assertion(&relay, subject, true, 500); + let inactive = assertion(&relay, subject, false, 501); + assert!(validate_event_disposition(&active, ProjectionDisposition::Active).is_ok()); + assert!(validate_event_disposition(&inactive, ProjectionDisposition::Inactive).is_ok()); + assert!(validate_event_disposition(&active, ProjectionDisposition::Inactive).is_err()); + assert!(validate_event_disposition(&inactive, ProjectionDisposition::Active).is_err()); + + let subject_hex = subject.to_hex(); + let stale_label = EventBuilder::new(Kind::Custom(ASSERTION_KIND 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("active tag"), + Tag::parse(["expiration", "0"]).expect("expiration tag"), + Tag::parse(["display_name", "Stale label"]).expect("display tag"), + ]) + .custom_created_at(Timestamp::from(502)) + .sign_with_keys(&relay) + .expect("sign malformed projection"); + assert!(validate_event_disposition(&stale_label, ProjectionDisposition::Inactive).is_err()); + + for (active, at) in [(true, 503), (false, 504)] { + let active_value = if active { "true" } else { "false" }; + let expiration = if active { "500" } else { "0" }; + let mut tags = vec![ + 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", active_value]).expect("active tag"), + Tag::parse(["expiration", expiration]).expect("expiration tag"), + ]; + if active { + tags.push( + Tag::parse(["display_name", "Private stale content"]).expect("display tag"), + ); + } + let nonempty = EventBuilder::new( + Kind::Custom(ASSERTION_KIND as u16), + "private content must never be projected", + ) + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(&relay) + .expect("sign non-canonical projection"); + let disposition = if active { + ProjectionDisposition::Active + } else { + ProjectionDisposition::Inactive + }; + assert!( + validate_event_disposition(&nonempty, disposition).is_err(), + "signed projection content must be empty" + ); + } + } + + #[test] + fn debug_receipts_redact_binding_and_public_key_coordinates() { + let claim = ProjectionRetirementClaim { + community_id: CommunityId::from_uuid(Uuid::new_v4()), + operation_id: Uuid::new_v4(), + relay_pubkey: vec![3; 32], + old_pubkey: vec![4; 32], + source_origin: Some(ProjectionBindingOrigin { + binding_id: Uuid::new_v4(), + binding_version: 7, + }), + claim_token: Uuid::new_v4(), + }; + let debug = format!("{claim:?}"); + assert!(!debug.contains(&hex::encode(&claim.relay_pubkey))); + assert!(!debug.contains(&hex::encode(&claim.old_pubkey))); + assert!(!debug.contains(&claim.operation_id.to_string())); + assert!(!debug.contains("7")); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 04b6a7ae36..559dbaddc2 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -821,10 +821,21 @@ pub async fn claim_due_match_batch( limit: i64, lease_until: DateTime, ) -> Result> { - claim_due_match_batch_with_loader( + claim_due_match_batch_excluding(pool, limit, lease_until, &[]).await +} + +/// Claim a matcher batch without touching exact protected Enforce domains. +pub async fn claim_due_match_batch_excluding( + pool: &PgPool, + limit: i64, + lease_until: DateTime, + excluded_communities: &[Uuid], +) -> Result> { + claim_due_match_batch_with_loader_excluding( pool, limit, lease_until, + excluded_communities, |pool, community, ids| async move { let refs: Vec<&[u8]> = ids.iter().map(Vec::as_slice).collect(); crate::event::get_events_by_ids(&pool, community, &refs).await @@ -833,10 +844,11 @@ pub async fn claim_due_match_batch( .await } -async fn claim_due_match_batch_with_loader( +async fn claim_due_match_batch_with_loader_excluding( pool: &PgPool, limit: i64, lease_until: DateTime, + excluded_communities: &[Uuid], load: F, ) -> Result> where @@ -850,6 +862,7 @@ where SELECT community_id FROM push_match_queue WHERE attempts < $3 + AND NOT (community_id = ANY($5::uuid[])) AND next_attempt_at <= now() AND (state = 'pending' OR (state = 'matching' AND lease_until < now())) ORDER BY next_attempt_at, created_at @@ -860,6 +873,7 @@ where FROM push_match_queue q JOIN target t ON q.community_id = t.community_id WHERE q.attempts < $3 + AND NOT (q.community_id = ANY($5::uuid[])) AND q.next_attempt_at <= now() AND (q.state = 'pending' OR (q.state = 'matching' AND q.lease_until < now())) ORDER BY q.next_attempt_at, q.created_at @@ -877,6 +891,7 @@ where .bind(lease_until) .bind(MAX_MATCH_ATTEMPTS) .bind(limit) + .bind(excluded_communities) .fetch_all(pool) .await?; if rows.is_empty() { @@ -931,11 +946,21 @@ where /// served by the due partial index, so putting it in every claim made claims /// slower exactly when a backlog needed them fastest. pub async fn reap_exhausted_matches(pool: &PgPool) -> Result { + reap_exhausted_matches_excluding(pool, &[]).await +} + +/// Reap exhausted matcher jobs outside exact protected Enforce domains. +pub async fn reap_exhausted_matches_excluding( + pool: &PgPool, + excluded_communities: &[Uuid], +) -> Result { Ok(sqlx::query( "DELETE FROM push_match_queue WHERE attempts >= $1 \ + AND NOT (community_id = ANY($2::uuid[])) \ AND (state='pending' OR (state='matching' AND lease_until < now()))", ) .bind(MAX_MATCH_ATTEMPTS) + .bind(excluded_communities) .execute(pool) .await? .rows_affected()) @@ -1891,6 +1916,18 @@ mod tests { .await .expect("read matcher queue"); assert_eq!(queued, vec![9]); + assert!( + claim_due_match_batch_excluding( + &pool, + 16, + Utc::now() + chrono::Duration::minutes(1), + &[*community.as_uuid()], + ) + .await + .expect("excluded protected matcher claim") + .is_none(), + "an excluded domain must remain unclaimed" + ); sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -1927,10 +1964,11 @@ mod tests { .await .expect("insert event"); - let error = claim_due_match_batch_with_loader( + let error = claim_due_match_batch_with_loader_excluding( &pool, 16, Utc::now() - chrono::Duration::seconds(1), + &[], |_pool, _community, _event_ids| async { Err(crate::DbError::InvalidData("injected load failure".into())) }, diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index 7189a2381f..a79c9afcee 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/relay_invite.rs @@ -22,7 +22,8 @@ use buzz_core::invite::{ V2_SECRET_LEN, }; use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; +use uuid::Uuid; use crate::error::Result; use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; @@ -113,6 +114,21 @@ pub async fn mint_relay_invite( created_by: &str, ttl_secs: u64, max_uses: Option, +) -> Result { + let mut transaction = pool.begin().await?; + let invite = + mint_relay_invite_tx(&mut transaction, community, created_by, ttl_secs, max_uses).await?; + transaction.commit().await?; + Ok(invite) +} + +/// Mint an invite inside a caller-owned authorization transaction. +pub async fn mint_relay_invite_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, ) -> Result { validate_mint_inputs(ttl_secs, max_uses)?; @@ -133,7 +149,7 @@ pub async fn mint_relay_invite( .bind(max_uses) .bind(expires_at) .bind(created_by) - .fetch_one(pool) + .fetch_one(&mut **transaction) .await?; let invite_id: uuid::Uuid = row.try_get("id")?; @@ -147,6 +163,32 @@ pub async fn mint_relay_invite( }) } +/// Lock and validate the relay role that may mint an invite. +/// +/// Enforcing callers invoke this inside the same transaction that writes the +/// invite and its authorization receipt. Legacy callers retain their existing +/// authorization flow. +pub async fn validate_relay_invite_minter_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + created_by: &str, +) -> Result<()> { + let role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(created_by) + .fetch_optional(&mut **transaction) + .await?; + if !matches!(role.as_deref(), Some("owner" | "admin")) { + return Err(crate::error::DbError::InvalidData( + "invite mint authority changed before commit".into(), + )); + } + Ok(()) +} + fn log_claim_outcome( community: CommunityId, invite_id: Option, @@ -174,17 +216,28 @@ const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; /// expiry index makes old rows drain first without turning cleanup into an /// unbounded transaction. pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + reap_expired_relay_invites_excluding(pool, cutoff, &[]).await +} + +/// Delete expired invites outside exact protected Enforce domains. +pub async fn reap_expired_relay_invites_excluding( + pool: &PgPool, + cutoff: DateTime, + excluded_communities: &[Uuid], +) -> Result { let result = sqlx::query( "DELETE FROM relay_invites \ WHERE (community_id, id) IN (\ SELECT community_id, id FROM relay_invites \ WHERE expires_at < $1 \ + AND NOT (community_id = ANY($3::uuid[])) \ ORDER BY expires_at \ LIMIT $2\ )", ) .bind(cutoff) .bind(RETENTION_SWEEP_BATCH_SIZE) + .bind(excluded_communities) .execute(pool) .await?; @@ -217,11 +270,41 @@ pub async fn claim_relay_invite_with_identity( claimer_pubkey: &str, policy_version: Option<&str>, identity: Option<&IdentityBindingInput<'_>>, +) -> Result { + let mut transaction = pool.begin().await?; + let outcome = claim_relay_invite_with_identity_tx( + &mut transaction, + community, + token_hash, + claimer_pubkey, + policy_version, + identity, + ) + .await?; + if matches!( + outcome, + ClaimOutcome::Joined { .. } | ClaimOutcome::AlreadyMember { .. } + ) { + transaction.commit().await?; + } else { + transaction.rollback().await?; + } + Ok(outcome) +} + +/// Stage invite consumption, binding, membership, and policy evidence inside +/// a caller-owned authorization transaction. +pub async fn claim_relay_invite_with_identity_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + identity: Option<&IdentityBindingInput<'_>>, ) -> Result { crate::identity_binding::validate_membership_identity_key(claimer_pubkey, identity)?; - let mut tx = pool.begin().await?; sqlx::query("SET LOCAL lock_timeout = '3s'") - .execute(&mut *tx) + .execute(&mut **tx) .await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. @@ -233,12 +316,11 @@ pub async fn claim_relay_invite_with_identity( ) .bind(community.as_uuid()) .bind(token_hash) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; // 3. No matching invite. let Some(invite) = row else { - tx.rollback().await?; log_claim_outcome(community, None, "invalid", None, None); return Ok(ClaimOutcome::Invalid); }; @@ -252,7 +334,6 @@ pub async fn claim_relay_invite_with_identity( // not authorize fresh policy-acceptance evidence, even for an existing // member; exhausted-but-live invites remain valid for idempotent retries. if expires_at <= Utc::now() { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -264,12 +345,10 @@ pub async fn claim_relay_invite_with_identity( } let identity_binding = if let Some(identity) = identity { - match crate::identity_binding::bind_or_validate_identity_tx(&mut tx, community, identity) - .await? + match crate::identity_binding::bind_or_validate_identity_tx(tx, community, identity).await? { binding @ (BindIdentityResult::Created | BindIdentityResult::Matched) => Some(binding), BindIdentityResult::Conflict(conflict) => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -280,7 +359,6 @@ pub async fn claim_relay_invite_with_identity( return Ok(ClaimOutcome::IdentityConflict(conflict)); } BindIdentityResult::Revoked => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -291,7 +369,6 @@ pub async fn claim_relay_invite_with_identity( return Ok(ClaimOutcome::IdentityRevoked); } BindIdentityResult::BindingRequired => { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -313,7 +390,7 @@ pub async fn claim_relay_invite_with_identity( sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(claimer_pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(&mut **tx) .await?; if existing.is_some() { @@ -326,10 +403,9 @@ pub async fn claim_relay_invite_with_identity( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } - tx.commit().await?; log_claim_outcome( community, Some(invite_id), @@ -347,7 +423,6 @@ pub async fn claim_relay_invite_with_identity( // 7. Capacity check. if let Some(mu) = max_uses { if use_count >= mu { - tx.rollback().await?; log_claim_outcome( community, Some(invite_id), @@ -369,7 +444,7 @@ pub async fn claim_relay_invite_with_identity( ) .bind(community.as_uuid()) .bind(claimer_pubkey) - .execute(&mut *tx) + .execute(&mut **tx) .await? .rows_affected() > 0; @@ -384,12 +459,11 @@ pub async fn claim_relay_invite_with_identity( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(&mut **tx) .await?; } if !inserted { - tx.commit().await?; log_claim_outcome( community, Some(invite_id), @@ -410,12 +484,9 @@ pub async fn claim_relay_invite_with_identity( .bind(new_use_count) .bind(community.as_uuid()) .bind(invite_id) - .execute(&mut *tx) + .execute(&mut **tx) .await?; - // 11. Commit. - tx.commit().await?; - let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); log_claim_outcome( @@ -803,6 +874,12 @@ mod tests { .await .expect("age old invite"); + assert_eq!( + reap_expired_relay_invites_excluding(&pool, cutoff, &[*community.as_uuid()]) + .await + .expect("exclude protected invites"), + 0 + ); assert_eq!( reap_expired_relay_invites(&pool, cutoff) .await diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index affddd8177..3e7ffd92ea 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -7,7 +7,7 @@ //! lowercase hex strings. use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row as _}; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; use crate::error::Result; use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; @@ -93,6 +93,35 @@ pub async fn get_relay_member( .map_err(crate::error::DbError::from) } +/// Return and share-lock a relay member inside a caller-owned authorization +/// transaction so a role decision remains stable through commit. +pub async fn get_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, +) -> Result> { + let row = sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at \ + FROM relay_members WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + + row.map(|r| -> std::result::Result { + Ok(RelayMember { + pubkey: r.try_get("pubkey")?, + role: r.try_get("role")?, + added_by: r.try_get("added_by")?, + created_at: r.try_get("created_at")?, + updated_at: r.try_get("updated_at")?, + }) + }) + .transpose() + .map_err(crate::error::DbError::from) +} + /// Returns all relay members of `community` ordered by `created_at` ascending. pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result> { let rows = sqlx::query( @@ -142,6 +171,27 @@ pub async fn add_relay_member( Ok(result.rows_affected() > 0) } +/// Transaction-owned relay member insertion. +pub async fn add_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + role: &str, + added_by: Option<&str>, +) -> Result { + let result = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(role) + .bind(added_by) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Claims relay membership via an invite and atomically persists policy evidence. /// /// Returns `true` when membership was inserted, or `false` when the pubkey was @@ -319,6 +369,35 @@ pub async fn remove_relay_member( } } +/// Transaction-owned relay member removal with owner protection. +pub async fn remove_relay_member_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(if exists.is_some() { + RemoveResult::IsOwner + } else { + RemoveResult::NotFound + }) +} + /// Removes a relay member only if their current role matches `expected_role`. /// /// The delete and the role check are collapsed into a single @@ -375,6 +454,38 @@ pub async fn remove_relay_member_if_role( } } +/// Transaction-owned role-conditional relay member removal. +pub async fn remove_relay_member_if_role_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + expected_role: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .execute(&mut **transaction) + .await?; + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + let role = sqlx::query_scalar::<_, String>( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(match role.as_deref() { + None => RemoveResult::NotFound, + Some("owner") => RemoveResult::IsOwner, + Some(_) => RemoveResult::RoleMismatch, + }) +} + /// Updates the role of an existing relay member in `community`. Returns `true` /// if updated. pub async fn update_relay_member_role( @@ -395,6 +506,25 @@ pub async fn update_relay_member_role( Ok(result.rows_affected() > 0) } +/// Transaction-owned role update with owner protection. +pub async fn update_relay_member_role_tx( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + pubkey: &str, + new_role: &str, +) -> Result { + let result = sqlx::query( + "UPDATE relay_members SET role = $1, updated_at = now() \ + WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", + ) + .bind(new_role) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut **transaction) + .await?; + Ok(result.rows_affected() > 0) +} + /// Ensures the configured owner pubkey holds the `"owner"` role *in /// `community`*, and demotes any other owners in that community to `"admin"`. /// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 066fb5f5c0..9902e9f003 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -4,6 +4,7 @@ use crate::error::Result; use buzz_core::CommunityId; use sqlx::PgPool; use sqlx::Row; +use sqlx::{Postgres, Transaction}; /// A user's profile fields. #[derive(Debug, Clone)] @@ -54,6 +55,63 @@ pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8] Ok(result.rows_affected() == 1) } +/// Ensure a user row exists inside a caller-owned transaction. +pub async fn ensure_user_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + let result = sqlx::query( + "INSERT INTO users (community_id, pubkey) VALUES ($1, $2) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Apply absolute kind:0 profile state inside a caller-owned transaction. +/// A contested NIP-05 handle leaves the prior handle unchanged while updating +/// the remaining fields, matching the legacy compatibility behavior. +#[allow(clippy::too_many_arguments)] +pub async fn replace_user_profile_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], + display_name: &str, + avatar_url: &str, + about: &str, + nip05_handle: &str, +) -> Result<()> { + let contested: bool = !nip05_handle.is_empty() + && sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE community_id = $1 \ + AND LOWER(nip05_handle) = LOWER($2) AND pubkey <> $3)", + ) + .bind(community_id.as_uuid()) + .bind(nip05_handle) + .bind(pubkey) + .fetch_one(&mut **tx) + .await?; + sqlx::query( + "UPDATE users SET display_name = NULLIF($1, ''), avatar_url = NULLIF($2, ''), \ + about = NULLIF($3, ''), nip05_handle = CASE WHEN $4 THEN nip05_handle \ + ELSE NULLIF($5, '') END WHERE community_id = $6 AND pubkey = $7", + ) + .bind(display_name) + .bind(avatar_url) + .bind(about) + .bind(contested) + .bind(nip05_handle) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Get a single user record by pubkey. pub async fn get_user( pool: &PgPool, @@ -368,6 +426,26 @@ pub async fn is_agent_owner( Ok(row.unwrap_or(false)) } +/// Share-lock and validate an agent-owner relationship inside a caller-owned +/// authorization transaction. +pub async fn is_agent_owner_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], +) -> Result { + let owner = sqlx::query_scalar::<_, Vec>( + "SELECT agent_owner_pubkey FROM users \ + WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL \ + FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(target_pubkey) + .fetch_optional(&mut **transaction) + .await?; + Ok(owner.is_some_and(|owner| owner == actor_pubkey)) +} + /// Set the channel_add_policy for a user. /// Returns an error if the pubkey is not found (rows_affected == 0). /// Returns an error if `policy` is not one of the valid ENUM values. @@ -398,6 +476,35 @@ pub async fn set_channel_add_policy( Ok(()) } +/// Set a channel-add policy inside a caller-owned transaction. +pub async fn set_channel_add_policy_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, +) -> Result<()> { + if !matches!(policy, "anyone" | "owner_only" | "nobody") { + return Err(crate::error::DbError::InvalidData(format!( + "invalid channel_add_policy: {policy}" + ))); + } + let result = sqlx::query( + "UPDATE users SET channel_add_policy = $1::channel_add_policy \ + WHERE community_id = $2 AND pubkey = $3", + ) + .bind(policy) + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(&mut **tx) + .await?; + if result.rows_affected() == 0 { + return Err(crate::error::DbError::NotFound( + "pubkey not found in users table".into(), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; 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-pubsub/src/authorization_invalidation.rs b/crates/buzz-pubsub/src/authorization_invalidation.rs new file mode 100644 index 0000000000..3894615b44 --- /dev/null +++ b/crates/buzz-pubsub/src/authorization_invalidation.rs @@ -0,0 +1,225 @@ +//! Provider-neutral authorization invalidation hints over Redis pub/sub. +//! +//! Hints contain no selector or identity data. The community is derived from +//! the server-owned Redis channel and the durable generation is reconciled +//! from Postgres by every consumer. + +use buzz_core::CommunityId; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::topic::BUZZ_PREFIX; + +/// Current provider-neutral hint wire version. +pub const AUTHORIZATION_INVALIDATION_WIRE_VERSION: u16 = 1; +/// Tenant-local Redis channel suffix. +pub const AUTHORIZATION_INVALIDATION_SUFFIX: &str = "authorization-invalidation"; +/// Pattern subscribed by relay nodes. +pub const AUTHORIZATION_INVALIDATION_PATTERN: &str = "buzz:*:authorization-invalidation"; + +/// Redis hint that a durable domain generation may have advanced. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthorizationInvalidationHint { + /// Envelope version. Unknown versions trigger a full durable reconcile. + pub wire_version: u16, + /// Highest generation known by the publisher after its commit. + pub generation: u64, +} + +impl AuthorizationInvalidationHint { + /// Construct a current-version hint for a positive durable generation. + pub const fn current(generation: u64) -> Self { + Self { + wire_version: AUTHORIZATION_INVALIDATION_WIRE_VERSION, + generation, + } + } +} + +/// A hint scoped by its server-owned Redis channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScopedAuthorizationInvalidationHint { + /// Authorization domain parsed from the channel. + pub community_id: CommunityId, + /// Provider-neutral durable-generation hint. + pub hint: AuthorizationInvalidationHint, +} + +/// Redis channel for one server-resolved authorization domain. +pub fn authorization_invalidation_channel(community_id: CommunityId) -> String { + format!("{BUZZ_PREFIX}:{community_id}:{AUTHORIZATION_INVALIDATION_SUFFIX}") +} + +/// Parse the exact authorization-invalidation channel shape. +pub fn parse_authorization_invalidation_channel(channel: &str) -> Option { + let mut parts = channel.split(':'); + if parts.next()? != BUZZ_PREFIX { + return None; + } + let community_id = Uuid::parse_str(parts.next()?).ok()?; + if parts.next()? != AUTHORIZATION_INVALIDATION_SUFFIX || parts.next().is_some() { + return None; + } + Some(CommunityId::from_uuid(community_id)) +} + +const BACKOFF_INITIAL_SECS: u64 = 1; +const BACKOFF_MAX_SECS: u64 = 30; + +/// Subscribe forever, reconnecting with bounded exponential backoff. +pub async fn run_authorization_invalidation_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + loop { + match connect_and_subscribe(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis authorization-invalidation stream ended; reconnecting in {backoff_secs}s" + ); + } + Err(error) => tracing::error!( + "Redis authorization-invalidation error: {error}; reconnecting in {backoff_secs}s" + ), + } + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + } +} + +async fn connect_and_subscribe( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut connection = client.get_async_pubsub().await?; + connection + .psubscribe(AUTHORIZATION_INVALIDATION_PATTERN) + .await?; + tracing::info!( + "Redis authorization-invalidation subscriber listening on {AUTHORIZATION_INVALIDATION_PATTERN}" + ); + + let mut stream = connection.on_message(); + while let Some(message) = stream.next().await { + let channel = message.get_channel_name(); + let Some(community_id) = parse_authorization_invalidation_channel(channel) else { + tracing::warn!("ignoring authorization-invalidation hint on unexpected channel"); + continue; + }; + let payload: String = match message.get_payload() { + Ok(payload) => payload, + Err(error) => { + tracing::warn!(%error, "ignoring unreadable authorization-invalidation hint"); + continue; + } + }; + let hint: AuthorizationInvalidationHint = match serde_json::from_str(&payload) { + Ok(hint) => hint, + Err(error) => { + tracing::warn!(%error, "ignoring malformed authorization-invalidation hint"); + continue; + } + }; + if broadcast_tx + .send(ScopedAuthorizationInvalidationHint { community_id, hint }) + .is_err() + { + tracing::trace!("no local authorization-invalidation receivers"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn channels_are_exactly_domain_scoped() { + let a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa)); + let b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb)); + assert_ne!( + authorization_invalidation_channel(a), + authorization_invalidation_channel(b) + ); + assert_eq!( + parse_authorization_invalidation_channel( + authorization_invalidation_channel(a).as_str() + ), + Some(a) + ); + } + + #[test] + fn rejects_ambiguous_channels() { + for channel in [ + "buzz:authorization-invalidation", + "buzz:not-a-uuid:authorization-invalidation", + "buzz:00000000-0000-0000-0000-00000000aaaa:authorization-invalidation:extra", + "other:00000000-0000-0000-0000-00000000aaaa:authorization-invalidation", + ] { + assert_eq!(parse_authorization_invalidation_channel(channel), None); + } + } + + #[test] + fn envelope_roundtrip_contains_only_version_and_generation() { + let payload = serde_json::to_string(&AuthorizationInvalidationHint::current(42)) + .expect("hint serializes"); + assert_eq!(payload, r#"{"wire_version":1,"generation":42}"#); + assert_eq!( + serde_json::from_str::(&payload) + .expect("hint deserializes"), + AuthorizationInvalidationHint::current(42) + ); + } + + #[test] + fn unknown_wire_version_remains_visible_to_reconciler() { + let hint: AuthorizationInvalidationHint = + serde_json::from_str(r#"{"wire_version":99,"generation":7}"#) + .expect("forward version remains decodable"); + assert_eq!(hint.wire_version, 99); + assert_eq!(hint.generation, 7); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_roundtrip_preserves_only_scoped_generation_hint() { + let pool = crate::test_util::make_test_pool(); + let manager = Arc::new( + crate::PubSubManager::new("redis://127.0.0.1:6379", pool) + .await + .expect("create pubsub manager"), + ); + let mut receiver = manager.subscribe_authorization_invalidations(); + let subscriber = manager.clone(); + let task = + tokio::spawn( + async move { subscriber.run_authorization_invalidation_subscriber().await }, + ); + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + manager + .publish_authorization_invalidation( + community_id, + AuthorizationInvalidationHint::current(11), + ) + .await + .expect("publish hint"); + let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), receiver.recv()) + .await + .expect("hint arrives") + .expect("broadcast remains open"); + assert_eq!(received.community_id, community_id); + assert_eq!(received.hint, AuthorizationInvalidationHint::current(11)); + task.abort(); + } +} diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 4f1690beef..3774e3172e 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -21,6 +21,8 @@ //! Pool connections handle all other commands. //! Lagged receivers get `RecvError::Lagged`. +/// Provider-neutral durable authorization invalidation hints. +pub mod authorization_invalidation; /// Cross-pod cache-key invalidation over Redis pub/sub. pub mod cache_invalidation; /// Cross-pod connection-control commands over Redis pub/sub. @@ -51,6 +53,10 @@ use buzz_core::TenantContext; use nostr::PublicKey; use tokio::sync::{broadcast, mpsc, Mutex}; +use crate::authorization_invalidation::{ + authorization_invalidation_channel, AuthorizationInvalidationHint, + ScopedAuthorizationInvalidationHint, +}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; @@ -66,6 +72,8 @@ pub struct ChannelEvent { pub topic: EventTopic, /// The Nostr event payload. pub event: nostr::Event, + /// Opaque relay-authenticated authority retained for protected ephemeral delivery. + pub authority: Option, } /// Configuration for the pub/sub subsystem. @@ -109,6 +117,7 @@ pub struct PubSubManager { subscription_rx: Mutex>>, broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, + authorization_invalidation_tx: broadcast::Sender, conn_control_tx: broadcast::Sender, } @@ -125,6 +134,7 @@ impl PubSubManager { ) -> Result { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); + let (authorization_invalidation_tx, _) = broadcast::channel(4096); let (conn_control_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); @@ -137,6 +147,7 @@ impl PubSubManager { subscription_rx: Mutex::new(Some(subscription_rx)), broadcast_tx, cache_invalidation_tx, + authorization_invalidation_tx, conn_control_tx, }) } @@ -170,6 +181,15 @@ impl PubSubManager { .await; } + /// Starts the authorization-invalidation hint subscriber with reconnects. + pub async fn run_authorization_invalidation_subscriber(self: Arc) { + authorization_invalidation::run_authorization_invalidation_subscriber( + self.redis_url.clone(), + self.authorization_invalidation_tx.clone(), + ) + .await; + } + /// Starts the connection-control subscriber loop with automatic /// reconnection. Runs forever — spawn this in a background task. pub async fn run_conn_control_subscriber(self: Arc) { @@ -260,6 +280,13 @@ impl PubSubManager { self.cache_invalidation_tx.subscribe() } + /// Returns a receiver for durable authorization-generation hints. + pub fn subscribe_authorization_invalidations( + &self, + ) -> broadcast::Receiver { + self.authorization_invalidation_tx.subscribe() + } + /// Returns a new broadcast receiver for cross-pod connection-control commands. pub fn subscribe_conn_control(&self) -> broadcast::Receiver { self.conn_control_tx.subscribe() @@ -284,6 +311,22 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a provider-neutral hint after a durable invalidation commit. + pub async fn publish_authorization_invalidation( + &self, + community_id: buzz_core::CommunityId, + hint: AuthorizationInvalidationHint, + ) -> Result { + let mut connection = self.pool.get().await?; + let payload = serde_json::to_string(&hint)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(authorization_invalidation_channel(community_id)) + .arg(payload) + .query_async(&mut connection) + .await?; + Ok(subscriber_count) + } + /// Publish a connection-control command to all pods. Used for live ban /// enforcement: the banning pod disconnects any local sockets synchronously /// and calls this to reach the banned member's sockets on other pods. The DB @@ -328,6 +371,17 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } + /// Publish an event with an opaque relay-owned authority envelope. + pub async fn publish_event_with_authority( + &self, + ctx: &TenantContext, + topic: EventTopic, + event: &nostr::Event, + authority: &str, + ) -> Result { + publisher::publish_event_with_authority(&self.pool, ctx, topic, event, authority).await + } + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, diff --git a/crates/buzz-pubsub/src/publisher.rs b/crates/buzz-pubsub/src/publisher.rs index 8ad06cc56b..c17a2b7f23 100644 --- a/crates/buzz-pubsub/src/publisher.rs +++ b/crates/buzz-pubsub/src/publisher.rs @@ -3,6 +3,7 @@ use buzz_core::TenantContext; use deadpool_redis::Pool; use nostr::JsonUtil; +use serde::Serialize; use uuid::Uuid; use crate::error::PubSubError; @@ -35,3 +36,28 @@ pub async fn publish_event( .await?; Ok(subscriber_count) } + +#[derive(Serialize)] +struct PublishedEventEnvelope<'a> { + event: &'a nostr::Event, + authority: &'a str, +} + +/// Publish one event with opaque relay-owned authority metadata. +pub async fn publish_event_with_authority( + pool: &Pool, + ctx: &TenantContext, + topic: EventTopic, + event: &nostr::Event, + authority: &str, +) -> Result { + let mut conn = pool.get().await?; + let key = crate::topic::EventTopicKey::from_context(ctx, topic).redis_channel(); + let payload = serde_json::to_string(&PublishedEventEnvelope { event, authority })?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(&key) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) +} diff --git a/crates/buzz-pubsub/src/subscriber.rs b/crates/buzz-pubsub/src/subscriber.rs index 88826ed99b..f7b4217044 100644 --- a/crates/buzz-pubsub/src/subscriber.rs +++ b/crates/buzz-pubsub/src/subscriber.rs @@ -6,11 +6,18 @@ use std::time::Duration; use futures_util::StreamExt; use nostr::JsonUtil; +use serde::Deserialize; use tokio::sync::{broadcast, mpsc, Mutex}; use crate::topic::EventTopicKey; use crate::ChannelEvent; +#[derive(Deserialize)] +struct PublishedEventEnvelope { + event: nostr::Event, + authority: String, +} + /// Initial reconnect backoff (1 second). const BACKOFF_INITIAL_SECS: u64 = 1; /// Maximum reconnect backoff (30 seconds). @@ -145,18 +152,22 @@ async fn connect_and_subscribe( } }; - let event = match nostr::Event::from_json(&payload) { - Ok(e) => e, - Err(e) => { - tracing::warn!("Failed to deserialize event from pub/sub: {e}"); - continue; - } + let (event, authority) = match serde_json::from_str::(&payload) { + Ok(envelope) => (envelope.event, Some(envelope.authority)), + Err(_) => match nostr::Event::from_json(&payload) { + Ok(event) => (event, None), + Err(e) => { + tracing::warn!("Failed to deserialize event from pub/sub: {e}"); + continue; + } + }, }; let channel_event = ChannelEvent { community_id: topic_key.community_id, topic: topic_key.topic, event, + authority, }; if let Err(_e) = broadcast_tx.send(channel_event) { 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/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5a8f24df89..4d1ef53d91 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,105 @@ 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_relationship( + actor, auth_tag, + ) { + Some(relationship) => buzz_auth::VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + proof, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + actor, + relationship.relationship_id(), + relationship.relationship_revision(), + 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 +288,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 +599,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 +612,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 +830,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 +839,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 +873,6 @@ pub async fn submit_event( .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -726,7 +884,6 @@ pub async fn submit_event( } SubmitOutcome::Rejected { kind, reason, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -737,7 +894,6 @@ pub async fn submit_event( } SubmitOutcome::Err { status, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = status.as_u16(), accepted = false, @@ -802,6 +958,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 +1034,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 +1094,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 +1188,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 +1197,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 +1242,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 +1295,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 +1742,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 +1751,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 +1793,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 +1838,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 +1891,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 +1916,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 +1929,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 +1966,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 +1993,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 +2005,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 +2042,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 +2105,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 +2114,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 +2236,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 +2280,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 +2477,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 +2569,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 +2588,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 +2597,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 +2622,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 +2728,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 +2782,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 +2829,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 +2947,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 +3804,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 +4132,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 +4149,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 +4518,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 +4578,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/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..0136ded773 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,209 @@ 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_relationship( + pubkey, auth_tag, + ) { + Some(relationship) => buzz_auth::VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_proof, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + 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 +590,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 +662,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 +715,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 +732,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 +1005,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 +1026,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 +1062,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 +1077,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 +1101,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 +1183,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 +1202,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 +1231,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 +1269,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 +1329,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 +1411,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 +1483,8 @@ pub async fn upload_pack( stream_git_read( repo, permit, + protected_authority, + read_fence, "upload-pack", &[], body, @@ -1055,7 +1528,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 +1556,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 +1610,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 +1624,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 +1644,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 +1672,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 +2008,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 +2046,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 +2069,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 +2177,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 +2209,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 +2245,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 +2310,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 +2359,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 +2367,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 +2463,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 +2474,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 +2490,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 +2497,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 +2663,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 +2689,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 +2704,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 +2740,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 +2906,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/invites.rs b/crates/buzz-relay/src/api/invites.rs index c8cd2b1121..24f0129679 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -23,8 +23,17 @@ use axum::{ }; use serde::Deserialize; use serde_json::Value; +use sha2::{Digest, Sha256}; +use crate::authorization_runtime::executor::{ + begin_authorized_enrollment, begin_authorized_operation, AuthorizedEnrollmentStart, + AuthorizedOperationStart, ProtectedOperationId, +}; +use crate::authorization_runtime::finalization::AuthorizationMode; +use crate::authorization_runtime::transport::authorize_enrollment_if_configured; +use crate::authorization_runtime::transport::authorize_if_configured; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use buzz_auth::AuthorizationCapability; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -108,6 +117,16 @@ pub struct AcceptPolicyRequest { pub age_confirmed: bool, } +fn stable_correlation_from_proof(proof: &buzz_auth::VerifiedNostrProof) -> uuid::Uuid { + let fingerprint = proof.operation_binding().fingerprint(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&fingerprint[..16]); + if bytes == [0; 16] { + bytes[15] = 1; + } + uuid::Uuid::from_bytes(bytes) +} + /// Public join policy shared by every client-side join surface. pub async fn join_policy(State(state): State>) -> Json { match &state.config.join_policy { @@ -236,7 +255,9 @@ async fn authenticate( ( buzz_core::TenantContext, nostr::PublicKey, - crate::corporate_identity::CorporateIdentityProof, + Option, + Arc, + Option>, ), (StatusCode, Json), > { @@ -254,34 +275,94 @@ async fn authenticate( })?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let (pubkey, event_id_bytes, verified_proof) = bridge::verify_protected_bridge_auth( headers, "POST", &url, Some(body), true, // invites always require NIP-98; no X-Pubkey dev fallback true, // POST bodies must be covered by a payload tag + tenant.community(), )?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + let authorization_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())); + if authorization_mode == Some(AuthorizationMode::DenyProtected) { + return Err(api_error( + StatusCode::FORBIDDEN, + "protected authorization denied", + )); + } + if authorization_mode != Some(AuthorizationMode::Enforce) { + 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()); - let identity_proof = crate::corporate_identity::verify_corporate_identity( + 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, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await - .map_err(|error| error.into_api_error())?; + { + Ok(proof) => Some(proof), + Err(error) + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational invite identity verification unavailable"); + None + } + Err(error) => return Err(error.into_api_error()), + }; - Ok((tenant, pubkey, identity_proof)) + let verified_proof = bridge::retain_bridge_proof(verified_proof, auth_tag)? + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "NIP-98 evidence required"))?; + + let enrollment_assertion = if state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())) + == Some(AuthorizationMode::Enforce) + { + let now = state + .corporate_identity + .as_ref() + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "relay identity verification failed"))? + .authorization_now() + .map_err(|error| error.into_api_error())?; + crate::corporate_identity::verified_assertion_for_proof( + identity_proof.as_ref().ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "relay identity verification failed") + })?, + tenant.community(), + buzz_auth::AuthTransport::HttpBridge, + now, + ) + .map_err(|error| error.into_api_error())? + .map(Arc::new) + } else { + None + }; + + Ok(( + tenant, + pubkey, + identity_proof, + verified_proof, + enrollment_assertion, + )) } async fn record_atomic_identity_rejection( @@ -314,7 +395,7 @@ pub async fn mint_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey, identity_proof) = + let (tenant, pubkey, identity_proof, verified_proof, verified_assertion) = authenticate(&state, &headers, "/api/invites", &body).await?; // Authz mirrors kind:9030 (add member): owner or admin only. @@ -344,24 +425,33 @@ pub async fn mint_invite( }; let (ttl, max_uses) = validate_mint_request(&request)?; - crate::corporate_identity::finalize_corporate_identity( + let protected_authority = authorize_if_configured( &state, - tenant.community(), - pubkey, - identity_proof, + Arc::clone(&verified_proof), + verified_assertion, + AuthorizationCapability::InviteMint, + stable_correlation_from_proof(&verified_proof), + "invite.mint", ) .await - .map_err(|error| error.into_api_error())?; - - // Mint a v2 opaque, database-backed invite. - let invite = state - .db - .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) - .await - .map_err(|error| match error { - buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), - error => internal_error(&format!("invite mint: {error}")), - })?; + .map_err(|error| { + tracing::warn!(error = %error, "invite mint: protected authorization denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + if crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } // Same TLS-posture logic as nip98_expected_url: wss deployments get an // https landing page URL, ws dev/test deployments get http. @@ -371,25 +461,90 @@ pub async fn mint_invite( "http" }; - tracing::info!( - community = %tenant.community(), - minted_by = %sender_hex, - invite_id = %invite.invite_id, - expires_at = %invite.expires_at, - max_uses = ?invite.max_uses, - "relay invite minted" - ); + let build_response = |invite: buzz_db::relay_invite::MintedInvite| { + tracing::info!( + community = %tenant.community(), + minted_by = %sender_hex, + invite_id = %invite.invite_id, + expires_at = %invite.expires_at, + max_uses = ?invite.max_uses, + "relay invite minted" + ); + serde_json::json!({ + "code": invite.code, + "expires_at": invite.expires_at.timestamp() as u64, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), + }) + }; - // expires_at as unix seconds for the response contract. - let expires_at_unix = invite.expires_at.timestamp() as u64; + if protected_authority.is_enforcing() { + let operation_id = ProtectedOperationId::derive( + tenant.community(), + "invite.mint.v1", + &verified_proof.operation_binding().fingerprint(), + ) + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + let mut digest = Sha256::new(); + digest.update(b"buzz-invite-mint-v1"); + digest.update(ttl.to_be_bytes()); + digest.update(max_uses.unwrap_or_default().to_be_bytes()); + let permit = protected_authority + .seal_postgres_mutation(operation_id, "invite.mint.v1", digest.finalize().into()) + .map_err(|error| { + tracing::warn!(error = %error, "invite mint: protected authorization denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })? + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + let response = match begin_authorized_operation(&state, permit) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))? + { + AuthorizedOperationStart::Replay(payload) => serde_json::from_slice(&payload) + .map_err(|error| internal_error(&format!("invite mint replay: {error}")))?, + AuthorizedOperationStart::Execute(mut operation) => { + buzz_db::relay_invite::validate_relay_invite_minter_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ) + .await + .map_err(|error| { + tracing::warn!(error = %error, "invite mint authority changed before commit"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let invite = buzz_db::relay_invite::mint_relay_invite_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ttl, + max_uses, + ) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + let response = build_response(invite); + let payload = serde_json::to_vec(&response) + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + operation + .commit(&payload) + .await + .map_err(|error| internal_error(&format!("invite mint: {error}")))?; + response + } + }; + return Ok(Json(response)); + } - Ok(Json(serde_json::json!({ - "code": invite.code, - "expires_at": expires_at_unix, - "max_uses": invite.max_uses, - "uses_remaining": invite.uses_remaining, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), - }))) + let invite = state + .db + .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .await + .map_err(|error| match error { + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), + error => internal_error(&format!("invite mint: {error}")), + })?; + Ok(Json(build_response(invite))) } /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the @@ -403,9 +558,20 @@ pub async fn claim_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey, identity_proof) = + let (tenant, pubkey, identity_proof, verified_proof, enrollment_assertion) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + let authorization_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())); + if authorization_mode == Some(AuthorizationMode::DenyProtected) { + return Err(api_error( + StatusCode::FORBIDDEN, + "protected authorization denied", + )); + } + let enforcing = authorization_mode == Some(AuthorizationMode::Enforce); + if claim_rate_limited(&state, tenant.community(), &pubkey) { return Err(api_error( StatusCode::TOO_MANY_REQUESTS, @@ -418,7 +584,10 @@ pub async fn claim_invite( // Invite admission must be coupled to the identity being admitted. A // delegated owner proof can become stale between verification and the // invite transaction, so bootstrap claims require the joiner's direct JWT. - if crate::corporate_identity::proof_is_delegated(&identity_proof) { + if identity_proof + .as_ref() + .is_some_and(crate::corporate_identity::proof_is_delegated) + { return Err(api_error( StatusCode::FORBIDDEN, "direct relay identity required for invite claim", @@ -428,6 +597,13 @@ pub async fn claim_invite( let claimer_hex = pubkey.to_hex(); let key = invite_token::derive_invite_key(&state.relay_keypair); + if enforcing && !request.code.starts_with(V2_PREFIX) { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_unavailable", + )); + } + // --- v2 database-backed path --- // // Route by exact prefix: v2. codes use the durable invite table. No @@ -448,8 +624,141 @@ pub async fn claim_invite( } let token_hash = hash_v2_code(&request.code); - let identity_binding = - crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + if enforcing { + let assertion = enrollment_assertion.ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "relay identity verification failed") + })?; + let enrollment = authorize_enrollment_if_configured( + &state, + Arc::clone(&verified_proof), + assertion, + stable_correlation_from_proof(&verified_proof), + ) + .await + .map_err(|error| { + tracing::warn!(error = %error, "invite claim: protected enrollment denied"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let mut stable_key = Vec::with_capacity(64); + stable_key.extend_from_slice(&token_hash); + stable_key.extend_from_slice(pubkey.as_bytes()); + let operation_id = + ProtectedOperationId::derive(tenant.community(), "invite.claim.v1", &stable_key) + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + let mut digest = Sha256::new(); + digest.update(b"buzz-invite-claim-v1"); + digest.update(token_hash); + digest.update(pubkey.as_bytes()); + if let Some(policy) = &state.config.join_policy { + digest.update(policy.version.as_bytes()); + } + let request_fingerprint: [u8; 32] = digest.finalize().into(); + let permit = enrollment + .seal_postgres_enrollment(operation_id, "invite.claim.v1", request_fingerprint) + .map_err(|error| { + tracing::warn!(error = %error, "invite claim: protected enrollment stale"); + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })? + .ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "protected authorization denied") + })?; + let response = match begin_authorized_enrollment(&state, permit) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))? + { + AuthorizedEnrollmentStart::Replay(payload) => serde_json::from_slice(&payload) + .map_err(|error| internal_error(&format!("invite claim replay: {error}")))?, + AuthorizedEnrollmentStart::Execute(mut operation) => { + let issuer = operation.issuer().to_owned(); + let subject = operation.subject().to_owned(); + let actor = *operation.actor_pubkey(); + let identity = buzz_db::identity_binding::IdentityBindingInput { + issuer: &issuer, + uid: &subject, + pubkey: &actor, + display_name: None, + source: buzz_db::identity_binding::SOURCE_JWT_NPUB, + }; + let outcome = buzz_db::relay_invite::claim_relay_invite_with_identity_tx( + operation.transaction(), + tenant.community(), + &token_hash, + &claimer_hex, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + Some(&identity), + ) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + let response = match outcome { + buzz_db::relay_invite::ClaimOutcome::Joined { .. } => serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }), + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + serde_json::json!({ + "status": "already_member", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }) + } + buzz_db::relay_invite::ClaimOutcome::Expired => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_expired")); + } + buzz_db::relay_invite::ClaimOutcome::Exhausted => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted")); + } + buzz_db::relay_invite::ClaimOutcome::Invalid => { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + } + buzz_db::relay_invite::ClaimOutcome::IdentityConflict(_) => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding conflict", + )); + } + buzz_db::relay_invite::ClaimOutcome::IdentityRevoked => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding revoked", + )); + } + buzz_db::relay_invite::ClaimOutcome::IdentityBindingRequired => { + return Err(api_error( + StatusCode::FORBIDDEN, + "relay identity binding required", + )); + } + }; + let payload = serde_json::to_vec(&response) + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + operation + .commit(&payload) + .await + .map_err(|error| internal_error(&format!("invite claim: {error}")))?; + response + } + }; + return Ok(Json(response)); + } + let legacy_identity = crate::authorization_runtime::transport::legacy_identity_lane( + &state, + tenant.community(), + ) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy; + let identity_binding = if legacy_identity { + identity_proof.as_ref().and_then(|proof| { + crate::corporate_identity::binding_input_for_proof(proof, &pubkey) + }) + } else { + None + }; let outcome = state .db .claim_relay_invite_with_identity( @@ -470,15 +779,19 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Joined { identity_binding, .. } => { - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } tracing::info!( community = %tenant.community(), member = %claimer_hex, @@ -503,15 +816,19 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::AlreadyMember { identity_binding, .. } => { - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } Ok(Json(serde_json::json!({ "status": "already_member", "community_id": tenant.community().to_string(), @@ -529,6 +846,9 @@ pub async fn claim_invite( Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) } buzz_db::relay_invite::ClaimOutcome::IdentityConflict(conflict) => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -539,6 +859,9 @@ pub async fn claim_invite( .await) } buzz_db::relay_invite::ClaimOutcome::IdentityRevoked => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -549,6 +872,9 @@ pub async fn claim_invite( .await) } buzz_db::relay_invite::ClaimOutcome::IdentityBindingRequired => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -582,8 +908,16 @@ pub async fn claim_invite( .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; } - let identity_binding = - crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + let legacy_identity = + crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy; + let identity_binding = if legacy_identity { + identity_proof + .as_ref() + .and_then(|proof| crate::corporate_identity::binding_input_for_proof(proof, &pubkey)) + } else { + None + }; let claim_outcome = state .db .claim_relay_membership_with_identity( @@ -605,6 +939,9 @@ pub async fn claim_invite( identity_binding, } => (inserted, identity_binding), buzz_db::relay_members::MembershipClaimOutcome::IdentityConflict(conflict) => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -615,6 +952,9 @@ pub async fn claim_invite( .await); } buzz_db::relay_members::MembershipClaimOutcome::IdentityRevoked => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -625,6 +965,9 @@ pub async fn claim_invite( .await); } buzz_db::relay_members::MembershipClaimOutcome::IdentityBindingRequired => { + let Some(identity_proof) = identity_proof else { + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + }; return Err(record_atomic_identity_rejection( &state, tenant.community(), @@ -635,15 +978,19 @@ pub async fn claim_invite( .await); } }; - crate::corporate_identity::finalize_atomic_corporate_identity_result( - &state, - tenant.community(), - pubkey, - identity_proof, - identity_binding, - ) - .await - .map_err(|error| error.into_api_error())?; + if legacy_identity { + if let Some(identity_proof) = identity_proof { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; + } + } if was_inserted { tracing::info!( diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index b2f633b33e..520eb84bfe 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,50 @@ 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_relationship( + auth_event.pubkey, + auth_tag, + ) { + Some(relationship) => VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_blossom, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + auth_event.pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + 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 +446,7 @@ impl FromRequestParts> for AuthenticatedUpload { auth_event, tenant, route_mode, + protected_authority, _upload_permit: upload_permit, }) } @@ -366,6 +524,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 +578,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 +596,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 +626,8 @@ pub async fn upload_blob( replay, content_length, attribution, + auth.protected_authority.as_ref(), + publication_mode, ) .await? } else { @@ -429,14 +651,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 +671,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 +714,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 +891,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 +924,53 @@ async fn authenticate_media_read( ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; + let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_relationship( + auth_event.pubkey, + auth_tag, + ) { + Some(relationship) => VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_blossom, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + auth_event.pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + 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 +1067,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 +1087,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 +1109,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 +1122,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 +1155,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 +1182,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 +1204,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 +1271,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 +1312,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 +1486,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 +1497,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 +1812,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/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b..0019ef51b5 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; @@ -92,16 +93,12 @@ pub mod relay_members { .await .map_err(|e| format!("relay membership check (owner) failed: {e}"))?; if owner_is_member { - debug!( - agent = %pubkey_hex, - owner = %owner_hex, - "NIP-OA membership granted via owner" - ); + debug!("NIP-OA membership granted via owner"); return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } Err(e) => { - info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); + info!("NIP-OA auth tag invalid: {e}"); } } } @@ -186,7 +183,7 @@ pub mod relay_members { Ok(true) => { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index c47e8df376..1f0f092403 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_db::channel::MemberRole; +use buzz_auth::{ + generate_challenge, AuthTransport, AuthorizationCapability, VerifiedDelegationOutput, + VerifiedEvidenceAdapter, +}; +use buzz_core::{tenant::TenantContext, CommunityId}; +use buzz_db::{authorization_invalidation::AuthorizationSessionTarget, 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_exact_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,9 +265,14 @@ 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 Ok(session_target) = AuthorizationSessionTarget::new(session_id, Uuid::new_v4()) else { + cancel.cancel(); + return; + }; let (mut ws_send, mut ws_recv) = socket.split(); let challenge = generate_challenge(); @@ -254,7 +293,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 +310,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 +327,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 +344,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 +384,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 +395,282 @@ async fn handle_active_audio_connection( return; } + let transport_delegation = crate::corporate_identity::verify_unconditional_nip_oa_relationship( + pubkey, + auth_tag_json.as_deref(), + ) + .map(|relationship| { + VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + 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_exact_session_if_configured( + &state, + Arc::clone(&verified_proof), + verified_assertion, + AuthorizationCapability::AudioJoin, + Uuid::from_bytes(correlation), + "audio.join", + session_target, + 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 +689,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 +723,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 +741,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 +758,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 +834,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 +842,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 +860,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 +878,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 +900,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 +973,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 +1413,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 +1460,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 +1471,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 +1602,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 +1636,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 +1713,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 +1733,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 +1752,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 +1781,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 +1797,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 +1848,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 +1855,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 +1875,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 +1884,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 +1905,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 +1922,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 +1956,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 +1975,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 +1984,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 +2552,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 +2602,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 +2612,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 +2674,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 +2707,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 +2740,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 +2758,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 +2785,7 @@ async fn audio_forward_loop( data_tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, + protected_authority: Arc, ) { loop { tokio::select! { @@ -1338,19 +2793,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 +2858,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 +2896,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 +2932,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 +3009,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 +3021,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 +3030,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 +3057,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 +3074,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/crates/buzz-relay/src/authorization_runtime/ephemeral.rs b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs new file mode 100644 index 0000000000..a557c1fc70 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/ephemeral.rs @@ -0,0 +1,406 @@ +//! Relay-authenticated authority carried only across ephemeral Redis fan-out. + +use std::{fmt, sync::Arc}; + +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use buzz_core::CommunityId; +use hmac::{Hmac, KeyInit, Mac}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::executor::{revalidate_ephemeral_claim, EphemeralAuthorityClaim}; +use super::transport::ProtectedAuthorization; +use crate::connection::QueuedOutboundReleaseFence; +use crate::state::AppState; + +const DOMAIN_SEPARATOR: &[u8] = b"buzz-ephemeral-redis-authority-v1"; +type HmacSha256 = Hmac; +const PRESENCE_PREFIX: &str = "pa1:"; + +#[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() + } +} + +pub(crate) fn encode_presence( + status: String, + context_id: [u8; 32], + authority: String, +) -> Result { + let value = serde_json::to_vec(&ProtectedPresenceValue { + status, + context_id, + authority, + })?; + Ok(format!( + "{PRESENCE_PREFIX}{}", + URL_SAFE_NO_PAD.encode(value) + )) +} + +pub(crate) fn decode_presence( + value: &str, +) -> Result, EphemeralAuthorityError> { + let Some(value) = value.strip_prefix(PRESENCE_PREFIX) else { + return Ok(None); + }; + let value = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + Ok(Some(serde_json::from_slice(&value)?)) +} + +/// Seal one provider-neutral sender authority for trusted relay Redis fan-out. +pub(crate) fn seal( + state: &AppState, + authority: &ProtectedAuthorization, + event: &nostr::Event, +) -> Result { + seal_context(state, authority, event.id.to_bytes()) +} + +/// Seal enforcing authority for a non-event ephemeral effect boundary. +pub(crate) fn seal_context( + state: &AppState, + authority: &ProtectedAuthorization, + context_id: [u8; 32], +) -> Result { + let claim = authority + .seal_ephemeral_delivery(context_id)? + .ok_or(EphemeralAuthorityError::AuthorityRequired)?; + seal_claim(&signing_key(state), &claim) +} + +fn seal_claim( + signing_key: &[u8; 32], + claim: &EphemeralAuthorityClaim, +) -> Result { + let payload = serde_json::to_vec(claim)?; + let mut mac = ::new_from_slice(signing_key) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + mac.update(DOMAIN_SEPARATOR); + mac.update(&(payload.len() as u64).to_be_bytes()); + mac.update(&payload); + let signature = mac.finalize().into_bytes(); + Ok(format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(payload), + URL_SAFE_NO_PAD.encode(signature) + )) +} + +/// Verify and preflight a remote sender authority before any local fan-out. +pub(crate) async fn verify( + state: &AppState, + community_id: CommunityId, + event: &nostr::Event, + token: &str, +) -> Result, EphemeralAuthorityError> { + let claim = verify_claim(&signing_key(state), token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != event.id.to_bytes() + || claim.actor_pubkey != event.pubkey.to_bytes() + || claim.capability != "community_write" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + let authority = Arc::new(RemoteEphemeralAuthority { + db: state.db.clone(), + claim, + }); + if !authority.release().await { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(authority) +} + +/// Database/signing material retained by an internal cross-node effect owner. +#[derive(Clone)] +pub(crate) enum AuthorityTokenVerifier { + Database { + db: buzz_db::Db, + signing_key: [u8; 32], + }, + #[cfg(test)] + TestAllow, + #[cfg(test)] + TestDeny, + #[cfg(test)] + TestConditional(Arc), +} + +/// Authority retained by an ephemeral effect owner through cleanup. The +/// absolute lease deadline is available to synchronous realtime boundaries; +/// invalidation and binding state remain asynchronously revalidated. +#[derive(Clone)] +pub(crate) struct RetainedEphemeralAuthority { + fence: 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.fence.release().await + } + + 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 from the shared relay signing secret without retaining it. + pub(crate) fn new(db: buzz_db::Db, relay_secret: &[u8]) -> Self { + Self::Database { + db, + signing_key: signing_key_from_secret(relay_secret), + } + } + + #[cfg(test)] + pub(crate) fn allow_for_test() -> Self { + Self::TestAllow + } + + #[cfg(test)] + pub(crate) fn deny_for_test() -> Self { + Self::TestDeny + } + + #[cfg(test)] + pub(crate) fn conditional_for_test(gate: Arc) -> Self { + Self::TestConditional(gate) + } + + /// Verify exact context and current database authority at the effect owner. + pub(crate) async fn verify_context( + &self, + community_id: CommunityId, + context_id: [u8; 32], + token: &str, + ) -> Result { + match self { + Self::Database { db, signing_key } => { + let claim = verify_claim(signing_key, token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != context_id + || claim.capability != "audio_join" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + let expires_at = claim.expires_at; + let authority = RetainedEphemeralAuthority { + fence: Arc::new(RemoteEphemeralAuthority { + db: db.clone(), + claim, + }), + expires_at, + }; + if !authority.release().await { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(authority) + } + #[cfg(test)] + Self::TestAllow => Ok(RetainedEphemeralAuthority { + fence: Arc::new(TestConditionalAuthority { + gate: Arc::new(std::sync::atomic::AtomicBool::new(true)), + }), + expires_at: test_authority_expiry(), + }), + #[cfg(test)] + Self::TestDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + #[cfg(test)] + Self::TestConditional(gate) => { + if !gate.load(std::sync::atomic::Ordering::SeqCst) { + return Err(EphemeralAuthorityError::ExpiredOrInvalidated); + } + Ok(RetainedEphemeralAuthority { + fence: Arc::new(TestConditionalAuthority { + gate: Arc::clone(gate), + }), + expires_at: test_authority_expiry(), + }) + } + } + } + + /// Verify exact context, actor, and current database authority. + pub(crate) async fn verify_actor_context( + &self, + community_id: CommunityId, + context_id: [u8; 32], + actor_pubkey: [u8; 32], + token: &str, + ) -> Result<(), EphemeralAuthorityError> { + match self { + Self::Database { db, signing_key } => { + let claim = verify_claim(signing_key, token)?; + if claim.community_id != *community_id.as_uuid() + || claim.event_id != context_id + || claim.actor_pubkey != actor_pubkey + || claim.capability != "community_write" + { + return Err(EphemeralAuthorityError::ContextMismatch); + } + revalidate_ephemeral_claim(db, &claim) + .await + .map_err(|_| EphemeralAuthorityError::ExpiredOrInvalidated) + } + #[cfg(test)] + Self::TestAllow => Ok(()), + #[cfg(test)] + Self::TestDeny => Err(EphemeralAuthorityError::ExpiredOrInvalidated), + #[cfg(test)] + Self::TestConditional(gate) => gate + .load(std::sync::atomic::Ordering::SeqCst) + .then_some(()) + .ok_or(EphemeralAuthorityError::ExpiredOrInvalidated), + } + } +} + +#[cfg(test)] +fn test_authority_expiry() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test wall clock after Unix epoch") + .as_secs() + + 3_600 +} + +#[cfg(test)] +struct TestConditionalAuthority { + gate: Arc, +} + +#[cfg(test)] +#[async_trait] +impl QueuedOutboundReleaseFence for TestConditionalAuthority { + async fn release(&self) -> bool { + self.gate.load(std::sync::atomic::Ordering::SeqCst) + } +} + +fn verify_claim( + signing_key: &[u8; 32], + token: &str, +) -> Result { + let (payload, signature) = token + .split_once('.') + .ok_or(EphemeralAuthorityError::InvalidEnvelope)?; + let payload = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| EphemeralAuthorityError::InvalidEnvelope)?; + let mut mac = ::new_from_slice(signing_key) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + mac.update(DOMAIN_SEPARATOR); + mac.update(&(payload.len() as u64).to_be_bytes()); + mac.update(&payload); + mac.verify_slice(&signature) + .map_err(|_| EphemeralAuthorityError::InvalidSignature)?; + Ok(serde_json::from_slice(&payload)?) +} + +fn signing_key(state: &AppState) -> [u8; 32] { + signing_key_from_secret(state.relay_keypair.secret_key().as_secret_bytes()) +} + +fn signing_key_from_secret(secret: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(DOMAIN_SEPARATOR); + digest.update(secret); + digest.finalize().into() +} + +struct RemoteEphemeralAuthority { + db: buzz_db::Db, + claim: EphemeralAuthorityClaim, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for RemoteEphemeralAuthority { + async fn release(&self) -> bool { + revalidate_ephemeral_claim(&self.db, &self.claim) + .await + .is_ok() + } +} + +#[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 signature is invalid")] + InvalidSignature, + #[error("ephemeral sender authority context does not match")] + ContextMismatch, + #[error("ephemeral sender authority is expired or invalidated")] + ExpiredOrInvalidated, + #[error("ephemeral sender authority could not be sealed")] + Transport(#[from] super::transport::ProtectedTransportError), + #[error("ephemeral sender authority serialization failed")] + Serialization(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protected_presence_round_trips_exact_authority_context() { + let context_id = [0x5a; 32]; + let encoded = encode_presence("online".into(), context_id, "sealed-authority".into()) + .expect("encode presence"); + let decoded = decode_presence(&encoded) + .expect("decode presence") + .expect("protected value"); + assert_eq!(decoded.status, "online"); + assert_eq!(decoded.context_id, context_id); + assert_eq!(decoded.authority, "sealed-authority"); + let debug = format!("{decoded:?}"); + assert!(!debug.contains("online")); + assert!(!debug.contains("sealed-authority")); + assert!(!debug.contains("5a5a")); + } + + #[test] + fn legacy_presence_is_distinct_from_protected_presence() { + assert!(decode_presence("online") + .expect("legacy presence is valid") + .is_none()); + } + + #[test] + fn malformed_protected_presence_fails_closed() { + assert!(matches!( + decode_presence("pa1:not-base64***"), + Err(EphemeralAuthorityError::InvalidEnvelope) + )); + } +} 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..e57573512c --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/executor.rs @@ -0,0 +1,1276 @@ +//! Transaction-owned protected mutation execution. +//! +//! A sealed permit is derived only from finalized enforcing authority. The +//! executor locks the durable invalidation and binding authorities, validates +//! expiry with the database clock, and commits the mutation and idempotency +//! receipt in one PostgreSQL transaction. + +use std::{fmt, sync::Arc}; + +use buzz_auth::{AuthorizationCapability, FederatedAuthorization}; +use buzz_core::CommunityId; +use buzz_db::authorization_invalidation::AuthorizationSelectorKind; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Row, Transaction}; +use thiserror::Error; +use uuid::Uuid; + +use super::transport::{ + LeaseCurrentStateError, ProtectedOperationAuthority, ProtectedTransportError, +}; + +const MAX_RESULT_BYTES: usize = 65_536; +const RESULT_VERSION: i16 = 1; + +/// One exact durable invalidation dependency carried to the commit boundary. +#[derive(Clone, PartialEq, Eq)] +pub struct CommitDependency { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, +} + +impl CommitDependency { + /// Preserve a dependency produced by the trusted invalidation runtime. + pub fn from_trusted_runtime( + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, + ) -> Result { + if (kind == AuthorizationSelectorKind::Binding) != binding_version.is_some() + || binding_version == Some(0) + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + kind, + fingerprint, + binding_version, + }) + } +} + +impl fmt::Debug for CommitDependency { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CommitDependency") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .field("binding_version", &"[redacted]") + .finish() + } +} + +/// Captured generation and complete selector set for one finalized authority. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationCommitFence { + evaluation_generation: u64, + dependencies: Vec, +} + +impl AuthorizationCommitFence { + /// Construct a commit fence from trusted finalization/invalidation state. + pub fn from_trusted_runtime( + evaluation_generation: u64, + mut dependencies: Vec, + ) -> Result { + if dependencies.is_empty() { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + let has_domain = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::Domain); + let has_binding = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::Binding); + let has_policy = dependencies + .iter() + .any(|dependency| dependency.kind == AuthorizationSelectorKind::PolicyVersion); + if !(has_domain && has_binding && has_policy) { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + evaluation_generation, + dependencies, + }) + } + + /// Construct a binding-independent fence for direct first enrollment. + pub fn from_trusted_enrollment_runtime( + evaluation_generation: u64, + mut dependencies: Vec, + ) -> Result { + if dependencies.is_empty() { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + let has = |kind| { + dependencies + .iter() + .any(|dependency| dependency.kind == kind) + }; + if !has(AuthorizationSelectorKind::Domain) + || !has(AuthorizationSelectorKind::PrincipalFingerprint) + || !has(AuthorizationSelectorKind::NostrKey) + || !has(AuthorizationSelectorKind::PolicyVersion) + || has(AuthorizationSelectorKind::Binding) + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + Ok(Self { + evaluation_generation, + dependencies, + }) + } +} + +impl fmt::Debug for AuthorizationCommitFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationCommitFence") + .field("evaluation_generation", &self.evaluation_generation) + .field("dependency_count", &self.dependencies.len()) + .finish() + } +} + +/// Signed Redis-safe snapshot of one finalized ephemeral sender authority. +/// +/// It contains only opaque fingerprints and stable binding identifiers. Raw +/// issuer, subject, profile, and policy values never enter the fan-out wire. +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct EphemeralAuthorityClaim { + pub(crate) community_id: Uuid, + pub(crate) event_id: [u8; 32], + pub(crate) actor_pubkey: [u8; 32], + pub(crate) bound_pubkey: [u8; 32], + pub(crate) binding_id: Uuid, + pub(crate) binding_version: u64, + pub(crate) expires_at: u64, + pub(crate) capability: String, + evaluation_generation: u64, + dependencies: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +struct EphemeralCommitDependency { + kind: String, + fingerprint: [u8; 32], + binding_version: Option, +} + +impl EphemeralAuthorityClaim { + pub(super) fn from_authority( + authority: &ProtectedOperationAuthority, + event_id: [u8; 32], + ) -> Result { + authority.revalidate()?; + let capability = ephemeral_capability_label(authority.capability()) + .ok_or(ProtectedTransportError::SurfaceCapabilityMismatch)?; + let context = authority.context(); + let lease = context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease)?; + let binding = match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + return Err(ProtectedTransportError::MissingAccessLease) + } + }; + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + Ok(Self { + community_id: *context.tenant().community().as_uuid(), + event_id, + actor_pubkey: context.pubkey().to_bytes(), + bound_pubkey: binding.bound_pubkey().to_bytes(), + binding_id: binding.binding_id(), + binding_version: binding.binding_version().get(), + expires_at: lease.expires_at(), + capability: capability.to_owned(), + evaluation_generation: fence.evaluation_generation, + dependencies: fence + .dependencies + .iter() + .map(|dependency| EphemeralCommitDependency { + kind: dependency.kind.as_str().to_owned(), + fingerprint: dependency.fingerprint, + binding_version: dependency.binding_version, + }) + .collect(), + }) + } +} + +/// Recheck a signed ephemeral claim against the writer database immediately +/// before a remote node releases it to a socket. +pub(crate) async fn revalidate_ephemeral_claim( + db: &buzz_db::Db, + claim: &EphemeralAuthorityClaim, +) -> Result<(), AuthorizationExecutionError> { + if claim.binding_version == 0 + || claim.dependencies.is_empty() + || !matches!(claim.capability.as_str(), "community_write" | "audio_join") + { + return Err(AuthorizationExecutionError::InvalidCommitFence); + } + let mut transaction = db.begin_transaction().await?; + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(claim.community_id) + .fetch_optional(&mut *transaction) + .await?; + let generation = generation.ok_or(AuthorizationExecutionError::Invalidated)?; + if generation < 0 || (generation as u64) < claim.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + for dependency in &claim.dependencies { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = $2 \ + AND selector_fingerprint = $3 FOR SHARE", + ) + .bind(claim.community_id) + .bind(&dependency.kind) + .bind(dependency.fingerprint.as_slice()) + .fetch_optional(&mut *transaction) + .await?; + let Some(row) = row else { continue }; + let floor_generation: i64 = row.try_get("generation")?; + let sticky_deny: bool = row.try_get("sticky_deny")?; + let binding_floor: Option = row.try_get("binding_version_floor")?; + let fenced_after_evaluation = + floor_generation < 0 || floor_generation as u64 > claim.evaluation_generation; + let binding_denied = match (binding_floor, dependency.binding_version) { + (Some(floor), Some(version)) => floor < 0 || version <= floor as u64, + _ => false, + }; + if sticky_deny || fenced_after_evaluation || binding_denied { + return Err(AuthorizationExecutionError::Invalidated); + } + } + let version = i64::try_from(claim.binding_version) + .map_err(|_| AuthorizationExecutionError::InvalidBinding)?; + let active: Option = sqlx::query_scalar( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 AND pubkey = $3 \ + AND binding_version = $4 AND binding_state = 'active' FOR SHARE", + ) + .bind(claim.community_id) + .bind(claim.binding_id) + .bind(claim.bound_pubkey.as_slice()) + .bind(version) + .fetch_optional(&mut *transaction) + .await?; + if active.is_none() { + return Err(AuthorizationExecutionError::InvalidBinding); + } + ensure_not_expired(&mut transaction, claim.expires_at).await?; + transaction.commit().await?; + Ok(()) +} + +/// 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 operation 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 = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + // Mark the digest-derived value as an RFC 4122 variant/version-5 UUID. + 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 sealed from a finalized enforcing authorization context. +pub struct SealedOperationPermit { + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + actor_pubkey: [u8; 32], + bound_pubkey: [u8; 32], + binding_id: Uuid, + binding_version: u64, + issuer: String, + subject: String, + expires_at: u64, + fence: AuthorizationCommitFence, +} + +impl SealedOperationPermit { + pub(super) fn from_authority( + authority: &ProtectedOperationAuthority, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result { + authority.revalidate()?; + let context = authority.context(); + let lease = context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease)?; + let binding = match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + return Err(ProtectedTransportError::MissingAccessLease) + } + }; + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-operation-request-v1"); + digest.update(context.tenant().community().as_uuid().as_bytes()); + digest.update(operation_id.as_uuid().as_bytes()); + digest.update((operation_kind.len() as u64).to_be_bytes()); + digest.update(operation_kind.as_bytes()); + let capability = capability_label(authority.capability()) + .ok_or(ProtectedTransportError::SurfaceCapabilityMismatch)?; + digest.update(capability.as_bytes()); + digest.update(context.pubkey().to_bytes()); + if let Some(owner) = context.agent_owner_pubkey() { + digest.update(owner.to_bytes()); + } + digest.update(lease.binding_id().as_bytes()); + digest.update(lease.binding_version().get().to_be_bytes()); + digest.update(lease.profile_id().as_str().as_bytes()); + digest.update(lease.policy_version().as_str().as_bytes()); + digest.update(lease.lease_version().get().to_be_bytes()); + digest.update(lease.expires_at().to_be_bytes()); + digest.update(request_fingerprint); + + Ok(Self { + community_id: context.tenant().community(), + operation_id, + operation_kind, + request_fingerprint: digest.finalize().into(), + actor_pubkey: context.pubkey().to_bytes(), + bound_pubkey: binding.bound_pubkey().to_bytes(), + binding_id: binding.binding_id(), + binding_version: binding.binding_version().get(), + issuer: binding.principal().issuer().to_owned(), + subject: binding.principal().subject().to_owned(), + expires_at: lease.expires_at(), + fence, + }) + } +} + +impl fmt::Debug for SealedOperationPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SealedOperationPermit") + .field("community_id", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("operation_kind", &self.operation_kind) + .field("authority", &"[redacted]") + .finish() + } +} + +/// Binding-independent permit for one atomic direct first enrollment. +pub struct SealedEnrollmentPermit { + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + actor_pubkey: [u8; 32], + issuer: String, + subject: String, + expires_at: u64, + fence: AuthorizationCommitFence, +} + +impl SealedEnrollmentPermit { + pub(super) fn from_authority( + authority: &super::transport::ProtectedEnrollmentAuthority, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result { + authority.revalidate()?; + let disposition = authority.disposition(); + let fence = authority + .observer() + .observe_commit_fence() + .map_err(ProtectedTransportError::CurrentState)?; + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-enrollment-request-v1"); + digest.update(disposition.authorization_domain().as_uuid().as_bytes()); + digest.update(operation_id.as_uuid().as_bytes()); + digest.update(operation_kind.as_bytes()); + digest.update(disposition.actor_pubkey().to_bytes()); + digest.update(disposition.principal().issuer().as_bytes()); + digest.update(disposition.principal().subject().as_bytes()); + digest.update(disposition.profile_id().as_str().as_bytes()); + digest.update(disposition.policy_version().as_str().as_bytes()); + digest.update(disposition.expires_at().to_be_bytes()); + digest.update(request_fingerprint); + Ok(Self { + community_id: disposition.authorization_domain(), + operation_id, + operation_kind, + request_fingerprint: digest.finalize().into(), + actor_pubkey: disposition.actor_pubkey().to_bytes(), + issuer: disposition.principal().issuer().to_owned(), + subject: disposition.principal().subject().to_owned(), + expires_at: disposition.expires_at(), + fence, + }) + } +} + +/// Start result for an idempotent protected operation. +pub enum AuthorizedOperationStart { + /// The same operation already committed; return this original typed payload. + Replay(Vec), + /// The caller owns the only transaction in which effects may be written. + Execute(Box), +} + +/// Start result for an atomic direct first enrollment. +pub enum AuthorizedEnrollmentStart { + /// The exact enrollment already committed. + Replay(Vec), + /// The caller owns the sole enrollment transaction. + Execute(Box), +} + +/// Open binding-independent enrollment transaction. +pub struct AuthorizedEnrollmentOperation { + transaction: Transaction<'static, Postgres>, + permit: SealedEnrollmentPermit, + restore: Arc, +} + +impl AuthorizedEnrollmentOperation { + /// Transaction used to bind identity, consume the invite, and add membership. + pub fn transaction(&mut self) -> &mut Transaction<'static, Postgres> { + &mut self.transaction + } + + /// Direct actor key bound by the staged assertion and Nostr proof. + pub const fn actor_pubkey(&self) -> &[u8; 32] { + &self.permit.actor_pubkey + } + + /// Literal validated issuer. + pub fn issuer(&self) -> &str { + &self.permit.issuer + } + + /// Literal validated subject. + pub fn subject(&self) -> &str { + &self.permit.subject + } + + /// Commit the enrollment and its bounded idempotency result together. + pub async fn commit( + mut self, + result_payload: &[u8], + ) -> Result, AuthorizationExecutionError> { + if result_payload.len() > MAX_RESULT_BYTES { + return Err(AuthorizationExecutionError::ResultTooLarge); + } + ensure_not_expired(&mut self.transaction, self.permit.expires_at).await?; + insert_receipt( + &mut self.transaction, + self.permit.community_id, + self.permit.operation_id, + self.permit.operation_kind, + &self.permit.request_fingerprint, + self.permit.expires_at, + result_payload, + ) + .await?; + let witness = self + .restore + .begin( + self.permit.community_id, + self.permit.operation_id.as_uuid(), + self.permit.request_fingerprint, + ) + .await?; + let commit_result = self.transaction.commit().await; + let witness_result = witness.commit().await; + if let Err(error) = witness_result { + return Err(error.into()); + } + if let Err(error) = commit_result { + // RestoreMutationGuard::commit returned success only after reading + // the exact durable operation receipt back from PostgreSQL. Never + // turn a bare failed commit acknowledgement into success. + tracing::warn!(%error, "protected enrollment commit acknowledgement was ambiguous; exact durable receipt verified"); + } + Ok(result_payload.to_vec()) + } +} + +/// Open transaction that has validated and locked its authorization authority. +pub struct AuthorizedOperation { + transaction: Transaction<'static, Postgres>, + permit: SealedOperationPermit, + restore: Option>, +} + +impl AuthorizedOperation { + /// Transaction to pass to transaction-aware mutation APIs. + pub fn transaction(&mut self) -> &mut Transaction<'static, Postgres> { + &mut self.transaction + } + + /// Atomically commit a bounded replay result with all transaction effects. + pub async fn commit( + mut self, + result_payload: &[u8], + ) -> Result, AuthorizationExecutionError> { + if result_payload.len() > MAX_RESULT_BYTES { + return Err(AuthorizationExecutionError::ResultTooLarge); + } + ensure_not_expired(&mut self.transaction, self.permit.expires_at).await?; + insert_receipt( + &mut self.transaction, + self.permit.community_id, + self.permit.operation_id, + self.permit.operation_kind, + &self.permit.request_fingerprint, + self.permit.expires_at, + result_payload, + ) + .await?; + let witness = match &self.restore { + Some(restore) => Some( + restore + .begin( + self.permit.community_id, + self.permit.operation_id.as_uuid(), + self.permit.request_fingerprint, + ) + .await?, + ), + #[cfg(test)] + None => None, + #[cfg(not(test))] + None => return Err(AuthorizationExecutionError::RestoreUnavailable), + }; + let commit_result = self.transaction.commit().await; + if let Some(witness) = witness { + witness.commit().await?; + if let Err(error) = commit_result { + // A successful witness commit proves that the exact receipt is + // durable. Without that proof the error above is returned. + tracing::warn!(%error, "protected operation commit acknowledgement was ambiguous; exact durable receipt verified"); + } + } else { + // Only test-only construction can omit restore protection. Keep + // even that path honest so a failed database commit is never + // reported as a successful protected effect. + commit_result?; + } + Ok(result_payload.to_vec()) + } +} + +#[allow(clippy::too_many_arguments)] +async fn insert_receipt( + transaction: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: &[u8; 32], + expires_at: u64, + result_payload: &[u8], +) -> Result<(), AuthorizationExecutionError> { + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7::double precision))", + ) + .bind(community_id.as_uuid()) + .bind(operation_id.as_uuid()) + .bind(operation_kind) + .bind(request_fingerprint.as_slice()) + .bind(RESULT_VERSION) + .bind(result_payload) + .bind(expires_at as f64) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +/// Begin a transaction-owned protected mutation or replay its original result. +pub async fn begin_authorized_operation( + state: &crate::state::AppState, + permit: SealedOperationPermit, +) -> Result { + let restore = state + .restore_protection() + .ok_or(AuthorizationExecutionError::RestoreUnavailable)?; + begin_authorized_operation_inner(&state.db, Some(Arc::clone(restore)), permit).await +} + +async fn begin_authorized_operation_inner( + db: &buzz_db::Db, + restore: Option>, + permit: SealedOperationPermit, +) -> Result { + let mut transaction = db.begin_transaction().await?; + + // Serialize identical retries before reading the receipt. This avoids two + // first attempts executing the mutation body concurrently. + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(permit.operation_id.as_uuid().to_string()) + .execute(&mut *transaction) + .await?; + + let committed_receipt = if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_version, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id = $1 AND operation_id = $2 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.operation_id.as_uuid()) + .fetch_optional(&mut *transaction) + .await? + { + let operation_kind: String = row.try_get("operation_kind")?; + let request_fingerprint: Vec = row.try_get("request_fingerprint")?; + let result_version: i16 = row.try_get("result_version")?; + let result_payload: Vec = row.try_get("result_payload")?; + if operation_kind != permit.operation_kind + || request_fingerprint.as_slice() != permit.request_fingerprint + || result_version != RESULT_VERSION + { + return Err(AuthorizationExecutionError::ConflictingRetry); + } + Some(result_payload) + } else { + None + }; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(permit.community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .fetch_one(&mut *transaction) + .await?; + if generation < 0 || (generation as u64) < permit.fence.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + + validate_dependency_floors(&mut transaction, permit.community_id, &permit.fence).await?; + validate_active_binding(&mut transaction, &permit).await?; + ensure_not_expired(&mut transaction, permit.expires_at).await?; + + if let Some(result_payload) = committed_receipt { + transaction.commit().await?; + return Ok(AuthorizedOperationStart::Replay(result_payload)); + } + + Ok(AuthorizedOperationStart::Execute(Box::new( + AuthorizedOperation { + transaction, + permit, + restore, + }, + ))) +} + +#[cfg(test)] +async fn begin_authorized_operation_for_test( + db: &buzz_db::Db, + permit: SealedOperationPermit, +) -> Result { + begin_authorized_operation_inner(db, None, permit).await +} + +/// Begin a binding-independent transaction that atomically creates first enrollment. +pub async fn begin_authorized_enrollment( + state: &crate::state::AppState, + permit: SealedEnrollmentPermit, +) -> Result { + let restore = state + .restore_protection() + .ok_or(AuthorizationExecutionError::RestoreUnavailable)?; + let mut transaction = state.db.begin_transaction().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(permit.operation_id.as_uuid().to_string()) + .execute(&mut *transaction) + .await?; + + let committed_receipt = if let Some(row) = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_version, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id = $1 AND operation_id = $2 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.operation_id.as_uuid()) + .fetch_optional(&mut *transaction) + .await? + { + let operation_kind: String = row.try_get("operation_kind")?; + let request_fingerprint: Vec = row.try_get("request_fingerprint")?; + let result_version: i16 = row.try_get("result_version")?; + let result_payload: Vec = row.try_get("result_payload")?; + if operation_kind != permit.operation_kind + || request_fingerprint.as_slice() != permit.request_fingerprint + || result_version != RESULT_VERSION + { + return Err(AuthorizationExecutionError::ConflictingRetry); + } + Some(result_payload) + } else { + None + }; + + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) \ + VALUES ($1) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(permit.community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id = $1 FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .fetch_one(&mut *transaction) + .await?; + if generation < 0 || (generation as u64) < permit.fence.evaluation_generation { + return Err(AuthorizationExecutionError::Invalidated); + } + validate_dependency_floors(&mut transaction, permit.community_id, &permit.fence).await?; + ensure_not_expired(&mut transaction, permit.expires_at).await?; + + if let Some(result_payload) = committed_receipt { + transaction.commit().await?; + return Ok(AuthorizedEnrollmentStart::Replay(result_payload)); + } + Ok(AuthorizedEnrollmentStart::Execute(Box::new( + AuthorizedEnrollmentOperation { + transaction, + permit, + restore: Arc::clone(restore), + }, + ))) +} + +async fn validate_dependency_floors( + transaction: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + fence: &AuthorizationCommitFence, +) -> Result<(), AuthorizationExecutionError> { + for dependency in &fence.dependencies { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors \ + WHERE community_id = $1 AND selector_kind = $2 \ + AND selector_fingerprint = $3", + ) + .bind(community_id.as_uuid()) + .bind(dependency.kind.as_str()) + .bind(dependency.fingerprint.as_slice()) + .fetch_optional(&mut **transaction) + .await?; + let Some(row) = row else { continue }; + let generation: i64 = row.try_get("generation")?; + let sticky_deny: bool = row.try_get("sticky_deny")?; + let binding_floor: Option = row.try_get("binding_version_floor")?; + let fenced_after_evaluation = + generation < 0 || generation as u64 > fence.evaluation_generation; + let binding_denied = match (binding_floor, dependency.binding_version) { + (Some(floor), Some(version)) => floor < 0 || version <= floor as u64, + _ => false, + }; + if sticky_deny || fenced_after_evaluation || binding_denied { + return Err(AuthorizationExecutionError::Invalidated); + } + } + Ok(()) +} + +async fn validate_active_binding( + transaction: &mut Transaction<'static, Postgres>, + permit: &SealedOperationPermit, +) -> Result<(), AuthorizationExecutionError> { + let version = i64::try_from(permit.binding_version) + .map_err(|_| AuthorizationExecutionError::InvalidBinding)?; + let active: Option = sqlx::query_scalar( + "SELECT 1 FROM identity_bindings \ + WHERE community_id = $1 AND binding_id = $2 \ + AND issuer = $3 AND uid = $4 AND pubkey = $5 \ + AND binding_version = $6 AND binding_state = 'active' \ + FOR SHARE", + ) + .bind(permit.community_id.as_uuid()) + .bind(permit.binding_id) + .bind(&permit.issuer) + .bind(&permit.subject) + .bind(permit.bound_pubkey.as_slice()) + .bind(version) + .fetch_optional(&mut **transaction) + .await?; + if active.is_none() { + return Err(AuthorizationExecutionError::InvalidBinding); + } + // The actor is included in the sealed fingerprint even when delegated; + // direct/owner agreement was already enforced by AuthContext finalization. + let _ = permit.actor_pubkey; + Ok(()) +} + +async fn ensure_not_expired( + transaction: &mut Transaction<'static, Postgres>, + expires_at: u64, +) -> Result<(), AuthorizationExecutionError> { + let current: f64 = + sqlx::query_scalar("SELECT EXTRACT(EPOCH FROM clock_timestamp())::double precision") + .fetch_one(&mut **transaction) + .await?; + if !current.is_finite() || current >= expires_at as f64 { + return Err(AuthorizationExecutionError::Expired); + } + Ok(()) +} + +const fn capability_label(capability: AuthorizationCapability) -> Option<&'static str> { + match capability { + AuthorizationCapability::CommunityRead => Some("community_read"), + AuthorizationCapability::CommunityWrite => Some("community_write"), + AuthorizationCapability::Moderate => Some("moderate"), + AuthorizationCapability::InviteMint => Some("invite_mint"), + AuthorizationCapability::InviteClaim => Some("invite_claim"), + AuthorizationCapability::MediaRead => Some("media_read"), + AuthorizationCapability::MediaWrite => Some("media_write"), + AuthorizationCapability::GitRead => Some("git_read"), + AuthorizationCapability::GitWrite => Some("git_write"), + AuthorizationCapability::AudioJoin => Some("audio_join"), + _ => None, + } +} + +const fn ephemeral_capability_label(capability: AuthorizationCapability) -> Option<&'static str> { + match capability { + AuthorizationCapability::CommunityWrite => Some("community_write"), + AuthorizationCapability::AudioJoin => Some("audio_join"), + _ => None, + } +} + +/// Fail-closed protected mutation execution error. +#[derive(Debug, Error)] +pub enum AuthorizationExecutionError { + /// Database operation failed. + #[error("protected operation database transaction failed")] + Database(#[from] sqlx::Error), + /// Database wrapper failed before the transaction began. + #[error("protected operation database transaction failed")] + Db(#[from] buzz_db::DbError), + /// Independent restore witness was unavailable or rejected reconciliation. + #[error("protected operation restore witness failed")] + Restore(#[from] super::restore::RestoreProtectionError), + /// Production execution was attempted without the mandatory witness. + #[error("protected operation restore witness is unavailable")] + RestoreUnavailable, + /// Captured generation or dependency set was incomplete. + #[error("protected operation commit fence is invalid")] + InvalidCommitFence, + /// Stable operation identity was empty or malformed. + #[error("protected operation identity is invalid")] + InvalidOperationIdentity, + /// The stable operation ID was reused for different input. + #[error("protected operation retry conflicts with the committed request")] + ConflictingRetry, + /// Current durable invalidation state denies the operation. + #[error("protected operation authority was invalidated")] + Invalidated, + /// The exact active binding changed or disappeared. + #[error("protected operation binding is no longer active")] + InvalidBinding, + /// The lease expired before the commit boundary. + #[error("protected operation authorization expired before commit")] + Expired, + /// Replay result exceeded the bounded receipt payload. + #[error("protected operation result is too large")] + ResultTooLarge, +} + +impl From for LeaseCurrentStateError { + fn from(_error: AuthorizationExecutionError) -> Self { + LeaseCurrentStateError::Unavailable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_ISSUER: &str = "https://idp.example"; + const TEST_SUBJECT: &str = "executor-subject"; + + fn dependency( + kind: AuthorizationSelectorKind, + marker: u8, + binding_version: Option, + ) -> CommitDependency { + CommitDependency::from_trusted_runtime(kind, [marker; 32], binding_version) + .expect("valid dependency") + } + + fn operation_fence() -> AuthorizationCommitFence { + AuthorizationCommitFence::from_trusted_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::Binding, 2, Some(1)), + dependency(AuthorizationSelectorKind::PolicyVersion, 3, None), + ], + ) + .expect("complete operation fence") + } + + fn epoch_after(seconds: u64) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_secs() + + seconds + } + + fn permit( + community_id: CommunityId, + binding_id: Uuid, + operation_id: ProtectedOperationId, + request_marker: u8, + expires_at: u64, + ) -> SealedOperationPermit { + SealedOperationPermit { + community_id, + operation_id, + operation_kind: "executor.test.v1", + request_fingerprint: [request_marker; 32], + actor_pubkey: [7; 32], + bound_pubkey: [7; 32], + binding_id, + binding_version: 1, + issuer: TEST_ISSUER.to_owned(), + subject: TEST_SUBJECT.to_owned(), + expires_at, + fence: operation_fence(), + } + } + + async fn integration_setup() -> (buzz_db::Db, CommunityId, Uuid) { + 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"); + buzz_db::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!( + "executor-{}.example", + community_id.as_uuid().simple() + )) + .execute(&pool) + .await + .expect("test community"); + let binding_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id, issuer, uid, pubkey, source, binding_id, \ + binding_version, binding_state, binding_provenance) \ + VALUES ($1, $2, $3, $4, 'jwt_npub', $5, 1, 'active', 'attested_key')", + ) + .bind(community_id.as_uuid()) + .bind(TEST_ISSUER) + .bind(TEST_SUBJECT) + .bind([7_u8; 32].as_slice()) + .bind(binding_id) + .execute(&pool) + .await + .expect("active test binding"); + (buzz_db::Db::from_pool(pool), community_id, binding_id) + } + + #[test] + fn stable_operation_identity_is_domain_and_kind_separated() { + let first_domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let second_domain = CommunityId::from_uuid(Uuid::from_u128(2)); + let first = ProtectedOperationId::derive(first_domain, "event.ingest.v1", b"event") + .expect("operation id"); + assert_eq!( + first, + ProtectedOperationId::derive(first_domain, "event.ingest.v1", b"event") + .expect("stable operation id") + ); + assert_ne!( + first, + ProtectedOperationId::derive(second_domain, "event.ingest.v1", b"event") + .expect("domain-separated operation id") + ); + assert_ne!( + first, + ProtectedOperationId::derive(first_domain, "invite.mint.v1", b"event") + .expect("kind-separated operation id") + ); + } + + #[test] + fn operation_and_enrollment_fences_require_distinct_authority_sets() { + assert!(AuthorizationCommitFence::from_trusted_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 3, None), + ], + ) + .is_err()); + assert!(AuthorizationCommitFence::from_trusted_enrollment_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PrincipalFingerprint, 2, None,), + dependency(AuthorizationSelectorKind::NostrKey, 3, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 4, None), + dependency(AuthorizationSelectorKind::Binding, 5, Some(1)), + ], + ) + .is_err()); + assert!(AuthorizationCommitFence::from_trusted_enrollment_runtime( + 0, + vec![ + dependency(AuthorizationSelectorKind::Domain, 1, None), + dependency(AuthorizationSelectorKind::PrincipalFingerprint, 2, None,), + dependency(AuthorizationSelectorKind::NostrKey, 3, None), + dependency(AuthorizationSelectorKind::PolicyVersion, 4, None), + ], + ) + .is_ok()); + let _ = operation_fence(); + } + + #[test] + fn capability_receipt_labels_are_stable() { + assert_eq!( + capability_label(AuthorizationCapability::CommunityWrite), + Some("community_write") + ); + assert_eq!( + capability_label(AuthorizationCapability::AudioJoin), + Some("audio_join") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::CommunityWrite), + Some("community_write") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::AudioJoin), + Some("audio_join") + ); + assert_eq!( + ephemeral_capability_label(AuthorizationCapability::MediaWrite), + None + ); + } + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn postgres_executor_serializes_retries_rolls_back_and_checks_expiry() { + let (db, community_id, binding_id) = integration_setup().await; + let concurrent_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"concurrent") + .expect("operation id"); + let run = |permit| async { + match begin_authorized_operation_for_test(&db, permit) + .await + .expect("authorized operation") + { + AuthorizedOperationStart::Execute(operation) => { + operation.commit(b"committed-once").await.expect("commit"); + true + } + AuthorizedOperationStart::Replay(payload) => { + assert_eq!(payload, b"committed-once"); + false + } + } + }; + let (first, second) = tokio::join!( + run(permit( + community_id, + binding_id, + concurrent_id, + 1, + epoch_after(60), + )), + run(permit( + community_id, + binding_id, + concurrent_id, + 1, + epoch_after(60), + )), + ); + assert_ne!(first, second, "exactly one concurrent retry executes"); + + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, concurrent_id, 2, epoch_after(60),), + ) + .await, + Err(AuthorizationExecutionError::ConflictingRetry) + )); + + let rollback_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"rollback") + .expect("operation id"); + let AuthorizedOperationStart::Execute(rolled_back) = begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, rollback_id, 3, epoch_after(60)), + ) + .await + .expect("open rollback operation") else { + panic!("uncommitted operation cannot replay") + }; + rolled_back + .transaction + .rollback() + .await + .expect("explicit rollback"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, rollback_id, 3, epoch_after(60),), + ) + .await + .expect("retry after rollback"), + AuthorizedOperationStart::Execute(_) + )); + + let expired_id = ProtectedOperationId::derive(community_id, "executor.test.v1", b"expired") + .expect("operation id"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit( + community_id, + binding_id, + expired_id, + 4, + epoch_after(0).saturating_sub(1), + ), + ) + .await, + Err(AuthorizationExecutionError::Expired) + )); + + let invalidation_id = Uuid::new_v4(); + let mut invalidation = db.begin_transaction().await.expect("invalidation tx"); + let invalidation_generation: i64 = sqlx::query_scalar( + "UPDATE authorization_invalidation_domains \ + SET generation = generation + 1 WHERE community_id = $1 \ + RETURNING generation", + ) + .bind(community_id.as_uuid()) + .fetch_one(&mut *invalidation) + .await + .expect("advance invalidation generation"); + sqlx::query( + "INSERT INTO authorization_invalidation_receipts \ + (community_id, event_id, generation, request_fingerprint) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(community_id.as_uuid()) + .bind(invalidation_id) + .bind(invalidation_generation) + .bind([9_u8; 32].as_slice()) + .execute(&mut *invalidation) + .await + .expect("invalidation receipt"); + sqlx::query( + "INSERT INTO authorization_invalidation_floors \ + (community_id, selector_kind, selector_fingerprint, generation, sticky_deny) \ + VALUES ($1, 'domain', $2, $3, true)", + ) + .bind(community_id.as_uuid()) + .bind([1_u8; 32].as_slice()) + .bind(invalidation_generation) + .execute(&mut *invalidation) + .await + .expect("domain deny floor"); + invalidation.commit().await.expect("commit invalidation"); + + let invalidated_id = + ProtectedOperationId::derive(community_id, "executor.test.v1", b"invalidated") + .expect("operation id"); + assert!(matches!( + begin_authorized_operation_for_test( + &db, + permit(community_id, binding_id, invalidated_id, 5, epoch_after(60),), + ) + .await, + Err(AuthorizationExecutionError::Invalidated) + )); + } +} 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..0f3522091c --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/finalization.rs @@ -0,0 +1,639 @@ +//! Relay adapter for exact-domain authorization policy and finalization. +//! +//! A request never selects its provider profile or activation mode. The relay +//! resolves one immutable policy from the row-zero [`TenantContext`] domain. +//! Missing and duplicate domain configuration fail closed without a global or +//! Nostr-only fallback. + +use std::{collections::HashMap, fmt, sync::Arc}; + +use buzz_auth::{ + resolve_authorization, AccessLeasePolicy, AuthorizationFinalizer, AuthorizationOutcome, + AuthorizationProfileId, AuthorizationProvider, AuthorizationRequest, BindingLeaseBound, + CapabilitySet, DecisionSource, EnrollmentMode, FederatedAuthorization, FederatedPrincipal, + FinalizationError, LeaseVersion, PolicyVersion, ProviderAuthorizationClock, + ProviderContractError, ProviderTimeout, ResolvedFederatedPolicy, SharedAuthorizationClock, + VerificationOnlyDisposition, VerificationStatusPolicy, VerifiedFederatedAssertion, + VerifiedNostrProof, VersionedBindingRef, +}; +use buzz_core::{tenant::TenantContext, CommunityId}; +use thiserror::Error; +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 binding or authority changes. + Shadow, + /// Produce a short-lived display-only result after full direct finalization. + VerifyOnly, + /// Issue bounded access leases after full direct or delegated finalization. + Enforce, + /// Keep every protected surface active while denying all protected access. + DenyProtected, +} + +impl AuthorizationMode { + /// Whether this mode may evaluate the configured admission provider. + pub const fn evaluates_provider(self) -> bool { + matches!(self, Self::Shadow | Self::VerifyOnly | Self::Enforce) + } + + /// Whether this mode keeps the protected-surface inventory authoritative. + pub const fn protects_surfaces(self) -> bool { + matches!(self, Self::Enforce | Self::DenyProtected) + } +} + +/// Immutable server configuration for one exact authorization domain. +#[derive(Clone)] +pub struct DomainAuthorizationPolicy { + authorization_domain: CommunityId, + profile_id: AuthorizationProfileId, + provider: Arc, + enrollment_mode: EnrollmentMode, + mode: AuthorizationMode, + provider_timeout: ProviderTimeout, + access_lease_policy: AccessLeasePolicy, + verification_status_policy: VerificationStatusPolicy, +} + +impl DomainAuthorizationPolicy { + /// Build policy exclusively from trusted server configuration. + #[allow(clippy::too_many_arguments)] + pub fn from_server_configuration( + authorization_domain: CommunityId, + profile_id: impl Into, + provider: Arc, + enrollment_mode: EnrollmentMode, + mode: AuthorizationMode, + provider_timeout: ProviderTimeout, + access_lease_policy: AccessLeasePolicy, + verification_status_policy: VerificationStatusPolicy, + ) -> Result { + Ok(Self { + authorization_domain, + profile_id: AuthorizationProfileId::from_server_configuration(profile_id)?, + provider, + enrollment_mode, + mode, + provider_timeout, + access_lease_policy, + verification_status_policy, + }) + } + + /// Exact server-owned authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact server-owned activation mode. + pub const fn mode(&self) -> AuthorizationMode { + self.mode + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Server-resolved binding enrollment mode. + pub const fn enrollment_mode(&self) -> EnrollmentMode { + self.enrollment_mode + } +} + +impl fmt::Debug for DomainAuthorizationPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DomainAuthorizationPolicy") + .field("authorization_domain", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("provider", &"[configured]") + .field("enrollment_mode", &"[redacted]") + .field("mode", &self.mode) + .field("provider_timeout", &"[redacted]") + .field("access_lease_policy", &"[redacted]") + .field("verification_status_policy", &"[redacted]") + .finish() + } +} + +/// Immutable exact-domain provider selector. +#[derive(Clone)] +pub struct DomainProviderSelector { + policies: HashMap, +} + +impl DomainProviderSelector { + /// Build an exact-domain selector, rejecting every ambiguous duplicate. + pub fn new( + policies: impl IntoIterator, + ) -> Result { + let mut by_domain = HashMap::new(); + for policy in policies { + let domain = policy.authorization_domain; + if by_domain.insert(domain, policy).is_some() { + return Err(DomainPolicyError::AmbiguousDomainPolicy); + } + } + Ok(Self { + policies: by_domain, + }) + } + + /// Resolve policy only from the row-zero server tenant. + /// + /// No default provider exists. A federated authorization attempt for an + /// unconfigured domain is denied as missing policy. + pub fn resolve( + &self, + tenant: &TenantContext, + ) -> Result<&DomainAuthorizationPolicy, DomainPolicyError> { + self.policies + .get(&tenant.community()) + .ok_or(DomainPolicyError::MissingDomainPolicy) + } +} + +impl fmt::Debug for DomainProviderSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DomainProviderSelector") + .field("policies", &"[redacted]") + .finish() + } +} + +/// Fail-closed exact-domain policy resolution error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum DomainPolicyError { + /// More than one authorization policy named the same exact domain. + #[error("federated authorization policy is ambiguous for this domain")] + AmbiguousDomainPolicy, + /// No federated policy was configured for this exact domain. + #[error("federated authorization policy is missing for this domain")] + MissingDomainPolicy, +} + +/// Provider-neutral relay finalizer with immutable policy and injected time. +#[derive(Clone)] +pub struct RelayAuthorizationFinalizer { + selector: DomainProviderSelector, + finalizer: AuthorizationFinalizer, + clock: SharedAuthorizationClock, + runtime_binding: Uuid, +} + +struct RelayProviderClock<'a>(&'a dyn buzz_auth::AuthorizationClock); + +impl ProviderAuthorizationClock for RelayProviderClock<'_> { + fn now_unix_seconds(&self) -> Option { + self.0.now().ok().map(|value| value.unix_seconds()) + } +} + +impl RelayAuthorizationFinalizer { + /// Build a runtime that shares one central clock across evaluation and finalization. + pub fn new(selector: DomainProviderSelector, clock: SharedAuthorizationClock) -> Self { + Self { + selector, + finalizer: AuthorizationFinalizer::new(Arc::clone(&clock)), + clock, + runtime_binding: Uuid::new_v4(), + } + } + + /// Evaluate current direct provider admission for a server-resolved domain. + /// + /// Off mode performs no provider call. All other modes preserve provider + /// deny and unavailable outcomes without falling back to another policy. + #[allow(clippy::too_many_arguments)] + pub async fn evaluate_direct( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if !policy.mode.evaluates_provider() { + return Err(RelayFinalizationError::ModeDoesNotEvaluate); + } + let now = self.finalizer.now()?; + let request = AuthorizationRequest::direct( + proof, + assertion, + federated_policy, + requested_capabilities, + correlation_id, + now.unix_seconds(), + )?; + Ok(resolve_authorization( + policy.provider.as_ref(), + &request, + &RelayProviderClock(self.clock.as_ref()), + policy.provider_timeout, + self.runtime_binding, + ) + .await) + } + + /// Evaluate current delegated-owner provider admission for an exact domain. + pub async fn evaluate_delegated( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if !policy.mode.evaluates_provider() { + return Err(RelayFinalizationError::ModeDoesNotEvaluate); + } + let now = self.finalizer.now()?; + let request = AuthorizationRequest::delegated_from_active_binding( + proof, + owner, + federated_policy, + requested_capabilities, + correlation_id, + now.unix_seconds(), + )?; + Ok(resolve_authorization( + policy.provider.as_ref(), + &request, + &RelayProviderClock(self.clock.as_ref()), + policy.provider_timeout, + self.runtime_binding, + ) + .await) + } + + /// Finalize one validated allow snapshot according to server-owned mode. + /// + /// Off and shadow modes cannot finalize a binding, status, or access + /// context. Verify-only returns a distinct display type; enforce is the + /// only branch capable of returning an access context with a lease. + pub fn finalize_allowed( + &self, + input: buzz_auth::AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + binding_bound: BindingLeaseBound, + lease_version: LeaseVersion, + ) -> Result { + let policy = self.selector.resolve(input.tenant())?; + match policy.mode { + AuthorizationMode::Off + | AuthorizationMode::Shadow + | AuthorizationMode::DenyProtected => Err(RelayFinalizationError::ModeDoesNotFinalize), + AuthorizationMode::VerifyOnly => self + .finalizer + .finalize_verification_only( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.verification_status_policy, + ) + .map(RuntimeAuthorizationDisposition::VerificationOnly) + .map_err(Into::into), + AuthorizationMode::Enforce => self + .finalizer + .finalize_access( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.access_lease_policy, + lease_version, + ) + .map(|context| RuntimeAuthorizationDisposition::Access(Box::new(context))) + .map_err(Into::into), + } + } + + /// Finalize the same current direct evidence into a short-lived, + /// display-only status. The caller separately proves the presentation + /// gate; this method cannot issue access or a lease and performs no writes. + pub fn finalize_client_status( + &self, + input: buzz_auth::AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + snapshot: Box, + binding_bound: BindingLeaseBound, + ) -> Result { + let policy = self.selector.resolve(input.tenant())?; + if !matches!( + policy.mode, + AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce + ) { + return Err(RelayFinalizationError::ModeDoesNotFinalize); + } + self.finalizer + .finalize_verification_only( + input, + federated_policy, + authorization, + snapshot, + &policy.profile_id, + binding_bound, + policy.verification_status_policy, + ) + .map_err(Into::into) + } + + /// Finalize direct first-enrollment evidence without creating a binding. + pub fn finalize_enrollment( + &self, + tenant: &TenantContext, + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + federated_policy: ResolvedFederatedPolicy, + snapshot: Box, + correlation_id: Uuid, + ) -> Result { + let policy = self.selector.resolve(tenant)?; + if policy.mode != AuthorizationMode::Enforce { + return Err(RelayFinalizationError::ModeDoesNotFinalize); + } + if policy.enrollment_mode != EnrollmentMode::AttestedKey { + return Err(RelayFinalizationError::EnrollmentModeUnsupported); + } + let now = self.finalizer.now()?; + let key = assertion + .key_attestation() + .ok_or(RelayFinalizationError::EnrollmentEvidenceMismatch)?; + if proof.verified_delegation().is_some() + || proof.authorization_domain() != tenant.community() + || federated_policy.authorization_domain() != tenant.community() + || !snapshot.is_bound_to_federated_policy(&federated_policy) + || assertion.authorization_domain() != tenant.community() + || snapshot.authorization_domain() != tenant.community() + || proof.authorized_transport() != assertion.authorized_transport() + || snapshot.transport() != proof.authorized_transport() + || snapshot.actor_pubkey() != proof.actor_pubkey() + || key.pubkey() != proof.actor_pubkey() + || snapshot.owner_pubkey().is_some() + || snapshot.binding_id().is_some() + || snapshot.binding_version().is_some() + || snapshot.proof_method() != proof.proof_method() + || snapshot.principal() != assertion.principal() + || snapshot.profile_id() != &policy.profile_id + || snapshot.decision_source() != DecisionSource::DirectAssertion + || snapshot.correlation_id() != correlation_id + || !snapshot + .capabilities() + .contains(buzz_auth::AuthorizationCapability::InviteClaim) + || snapshot.issued_at() > now.unix_seconds() + || snapshot.fresh_until() <= now.unix_seconds() + || snapshot.effective_until() <= now.unix_seconds() + || assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now.unix_seconds())) + || assertion.expires_at().is_expired_at(now.unix_seconds()) + { + return Err(RelayFinalizationError::EnrollmentEvidenceMismatch); + } + let application_until = now + .unix_seconds() + .checked_add(policy.access_lease_policy.application_limit().seconds()) + .ok_or(RelayFinalizationError::EnrollmentEvidenceMismatch)?; + let expires_at = snapshot + .effective_until() + .min(assertion.expires_at().unix_seconds()) + .min(application_until) + .saturating_sub(policy.access_lease_policy.clock_skew().seconds()); + if expires_at <= now.unix_seconds() { + return Err(RelayFinalizationError::EnrollmentEvidenceMismatch); + } + Ok(EnrollmentDisposition { + authorization_domain: tenant.community(), + actor_pubkey: proof.actor_pubkey(), + principal: assertion.principal().clone(), + profile_id: policy.profile_id.clone(), + policy_version: snapshot.policy_version().clone(), + correlation_id, + expires_at, + }) + } +} + +/// Direct provider decision sealed for atomic first enrollment. +#[must_use] +pub struct EnrollmentDisposition { + authorization_domain: CommunityId, + actor_pubkey: nostr::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 is attested by the assertion. + pub const fn actor_pubkey(&self) -> nostr::PublicKey { + self.actor_pubkey + } + /// Literal issuer-qualified principal staged for enrollment. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + /// Server-selected authorization profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + /// Provider policy version that authorized the enrollment. + 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 the enrollment transaction. + 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() + } +} + +impl fmt::Debug for RelayAuthorizationFinalizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayAuthorizationFinalizer") + .field("selector", &self.selector) + .field("finalizer", &self.finalizer) + .finish() + } +} + +/// Typed result of server-mode finalization. +#[must_use] +pub enum RuntimeAuthorizationDisposition { + /// Enforcing authority carrying a bounded access lease. + Access(Box), + /// Display-only verification carrying no access authority. + VerificationOnly(VerificationOnlyDisposition), +} + +impl fmt::Debug for RuntimeAuthorizationDisposition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Access(_) => formatter + .debug_tuple("Access") + .field(&"[redacted]") + .finish(), + Self::VerificationOnly(_) => formatter + .debug_tuple("VerificationOnly") + .field(&"[redacted]") + .finish(), + } + } +} + +/// Fail-closed relay finalization adapter error. +#[derive(Debug, Error)] +pub enum RelayFinalizationError { + /// Exact-domain server policy could not be resolved. + #[error(transparent)] + DomainPolicy(#[from] DomainPolicyError), + /// Central authorization time was unavailable. + #[error(transparent)] + Clock(#[from] buzz_auth::AuthorizationClockError), + /// Provider request evidence was inconsistent. + #[error(transparent)] + ProviderContract(#[from] ProviderContractError), + /// Full provider/binding finalization failed. + #[error(transparent)] + Finalization(#[from] FinalizationError), + /// A non-evaluating mode was asked to evaluate federated policy. + #[error("server-resolved authorization mode does not evaluate federated policy")] + ModeDoesNotEvaluate, + /// A non-finalizing mode was asked to create status or authority. + #[error("server-resolved authorization mode does not permit finalization")] + ModeDoesNotFinalize, + /// First enrollment was configured for a non-attested mode. + #[error("server-resolved authorization policy does not permit direct enrollment")] + EnrollmentModeUnsupported, + /// Direct assertion, provider, proof, or expiry evidence did not match. + #[error("direct enrollment evidence is inconsistent or stale")] + EnrollmentEvidenceMismatch, +} + +#[cfg(test)] +mod tests { + use std::{future::ready, time::Duration}; + + use buzz_auth::{ + ApplicationLeaseLimit, AuthorizationClockSkew, AuthorizationDenial, + AuthorizationDenialReason, AuthorizationProviderFuture, ProviderDecision, + }; + use uuid::Uuid; + + use super::*; + + struct DenyProvider; + + impl AuthorizationProvider for DenyProvider { + fn profile_id(&self) -> buzz_auth::AuthorizationProfileId { + buzz_auth::AuthorizationProfileId::from_server_configuration( + "profile.synthetic-deny.example", + ) + .expect("synthetic profile is valid") + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(ready(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )))) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn policy(domain: CommunityId) -> DomainAuthorizationPolicy { + let application_limit = + ApplicationLeaseLimit::from_seconds(300).expect("synthetic limit is valid"); + let skew = AuthorizationClockSkew::from_seconds(5).expect("synthetic skew is valid"); + DomainAuthorizationPolicy::from_server_configuration( + domain, + "synthetic-provider.example", + Arc::new(DenyProvider), + EnrollmentMode::Provisioned, + AuthorizationMode::Enforce, + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + AccessLeasePolicy::new(application_limit, skew), + VerificationStatusPolicy::new(application_limit, skew), + ) + .expect("synthetic policy is valid") + } + + #[test] + fn duplicate_domain_policy_is_rejected_as_ambiguous() { + let exact_domain = domain(1); + let result = DomainProviderSelector::new([policy(exact_domain), policy(exact_domain)]); + assert!(matches!( + result, + Err(DomainPolicyError::AmbiguousDomainPolicy) + )); + } + + #[test] + fn missing_domain_has_no_default_provider_fallback() { + let configured = domain(1); + let missing = domain(2); + let selector = DomainProviderSelector::new([policy(configured)]) + .expect("one exact policy is unambiguous"); + let tenant = TenantContext::resolved(missing, "missing.authorization.example"); + assert!(matches!( + selector.resolve(&tenant), + Err(DomainPolicyError::MissingDomainPolicy) + )); + } + + #[test] + fn exact_server_tenant_selects_its_policy() { + let configured = domain(1); + let selector = DomainProviderSelector::new([policy(configured)]) + .expect("one exact policy is unambiguous"); + let tenant = TenantContext::resolved(configured, "configured.authorization.example"); + let resolved = selector + .resolve(&tenant) + .expect("exact domain is configured"); + assert_eq!(resolved.authorization_domain(), configured); + assert_eq!(resolved.mode(), AuthorizationMode::Enforce); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/invalidation.rs b/crates/buzz-relay/src/authorization_runtime/invalidation.rs new file mode 100644 index 0000000000..88d090e892 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/invalidation.rs @@ -0,0 +1,2021 @@ +//! Durable authorization invalidation, reconciliation, and use fences. +//! +//! Postgres is authoritative. Redis messages only accelerate reconciliation; +//! startup, polling, lag recovery, and every error path fail closed. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock, Weak}; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + AuthContext, FederatedAuthorization, FederatedIdentityRequirement, VerificationOnlyDisposition, +}; +use buzz_core::{CommunityId, TenantContext}; +use buzz_db::authorization_invalidation::{ + AuthorizationInvalidationEntry, AuthorizationInvalidationFloor, + AuthorizationInvalidationReceipt, AuthorizationInvalidationRequest, + AuthorizationInvalidationResult, AuthorizationInvalidationSnapshot, AuthorizationSelector, + AuthorizationSelectorKind, AuthorizationSessionTarget, +}; +use buzz_db::{Db, DbError}; +use buzz_pubsub::authorization_invalidation::{ + AuthorizationInvalidationHint, ScopedAuthorizationInvalidationHint, + AUTHORIZATION_INVALIDATION_WIRE_VERSION, +}; +use buzz_pubsub::PubSubManager; +use dashmap::DashMap; +use thiserror::Error; +use tokio::sync::Mutex; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +/// Default interval between durable reconciliation reads. +pub const DEFAULT_INVALIDATION_POLL_INTERVAL: Duration = Duration::from_secs(5); +/// Default maximum age of a successful authority read. +pub const DEFAULT_INVALIDATION_MAX_STALENESS: Duration = Duration::from_secs(15); + +/// Runtime polling and freshness bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AuthorizationInvalidationConfig { + poll_interval: Duration, + max_staleness: Duration, +} + +impl AuthorizationInvalidationConfig { + /// Build non-zero polling and staleness bounds. + pub fn new( + poll_interval: Duration, + max_staleness: Duration, + ) -> Result { + if poll_interval.is_zero() || max_staleness.is_zero() { + return Err(AuthorizationInvalidationRuntimeError::InvalidConfiguration); + } + Ok(Self { + poll_interval, + max_staleness, + }) + } + + /// Durable reconciliation interval. + pub const fn poll_interval(self) -> Duration { + self.poll_interval + } + + /// Maximum permitted age of a successful writer-database read. + pub const fn max_staleness(self) -> Duration { + self.max_staleness + } +} + +impl Default for AuthorizationInvalidationConfig { + fn default() -> Self { + Self { + poll_interval: DEFAULT_INVALIDATION_POLL_INTERVAL, + max_staleness: DEFAULT_INVALIDATION_MAX_STALENESS, + } + } +} + +/// Fail-closed runtime failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)] +pub enum AuthorizationInvalidationRuntimeError { + /// Configuration contained a zero bound. + #[error("authorization invalidation configuration is invalid")] + InvalidConfiguration, + /// Lease dependencies could not be represented exactly. + #[error("authorization invalidation dependencies are invalid")] + InvalidDependencies, + /// Admission-loss selectors or idempotency evidence were invalid. + #[error("authorization admission-loss event is invalid")] + InvalidAdmissionLoss, + /// No successful startup/recovery snapshot is installed for the domain. + #[error("authorization invalidation domain is not ready")] + NotReady, + /// Durable authority was not read within the configured freshness bound. + #[error("authorization invalidation authority is stale")] + Stale, + /// A matching selector floor invalidated the observed authority. + #[error("authorization authority was invalidated")] + Invalidated, + /// Durable generation or floor state moved backwards. + #[error("authorization invalidation authority regressed")] + AuthorityRegressed, + /// The writer-database read failed. + #[error("authorization invalidation authority is unavailable")] + AuthorityUnavailable, + /// Redis hint publication failed after durable commit. + #[error("authorization invalidation hint publication failed")] + HintUnavailable, + /// The runtime no longer exists. + #[error("authorization invalidation runtime stopped")] + RuntimeStopped, +} + +/// Provider-neutral description of reversible admission loss. +/// +/// The event carries only selector material and an idempotency ID. Its exact +/// authorization domain is supplied separately by a server-resolved +/// [`TenantContext`] when the event is committed. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationAdmissionLoss { + request: AuthorizationInvalidationRequest, +} + +impl AuthorizationAdmissionLoss { + /// Build one bounded event for exact principal, Nostr-key, or + /// delegated-owner selectors. + pub fn new( + event_id: Uuid, + selectors: Vec, + ) -> Result { + let entries = selectors + .into_iter() + .map(AuthorizationInvalidationEntry::admission_loss_fence) + .collect::, _>>() + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss)?; + let request = AuthorizationInvalidationRequest::new(event_id, entries) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss)?; + Ok(Self { request }) + } + + /// Idempotency identifier retained by the durable invalidation authority. + pub const fn event_id(&self) -> Uuid { + self.request.event_id() + } +} + +impl fmt::Debug for AuthorizationAdmissionLoss { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationAdmissionLoss") + .field("event_id", &"[redacted]") + .field("selector_count", &self.request.entries().len()) + .finish() + } +} + +/// Generation captured immediately before provider evaluation. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationEvaluationFence { + community_id: CommunityId, + generation: u64, +} + +impl AuthorizationEvaluationFence { + /// Server-resolved authorization domain. + pub const fn community_id(self) -> CommunityId { + self.community_id + } + + /// Durable generation captured before evaluation. + pub const fn generation(self) -> u64 { + self.generation + } +} + +impl fmt::Debug for AuthorizationEvaluationFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationEvaluationFence") + .field("community_id", &"[redacted]") + .field("generation", &self.generation) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +struct Dependency { + kind: AuthorizationSelectorKind, + fingerprint: [u8; 32], + binding_version: Option, +} + +/// Exact selector dependencies represented by one finalized lease and session. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationDependencies { + community_id: CommunityId, + selectors: Vec, +} + +impl AuthorizationDependencies { + /// Derive exact invalidation dependencies for a display-only current + /// direct binding. This registration can withdraw presentation but cannot + /// authorize access or create a lease. + pub fn from_verification_only( + session_target: Option, + disposition: &VerificationOnlyDisposition, + ) -> Result { + let mut selectors = vec![ + AuthorizationSelector::nostr_key(disposition.actor_pubkey().to_bytes()), + AuthorizationSelector::binding( + disposition.binding_id(), + disposition.binding_version().get(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } + Self::from_selectors(disposition.authorization_domain(), selectors) + } + + /// Derive the pre-binding dependencies for one staged direct enrollment. + /// The transaction revalidates these selectors before it may create the + /// first binding or membership row. + pub fn from_enrollment( + session_target: Option, + disposition: &super::finalization::EnrollmentDisposition, + ) -> Result { + let actor = disposition.actor_pubkey().to_bytes(); + let mut selectors = vec![ + AuthorizationSelector::principal( + disposition.principal().issuer(), + disposition.principal().subject(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::nostr_key(actor), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } + Self::from_selectors(disposition.authorization_domain(), selectors) + } + + /// Derive every invalidation dependency from one finalized enforcing + /// context and a server-owned non-nil session ID. + /// + /// The principal is read from the same active binding that the finalizer + /// bound to the lease. It is never accepted as an independent argument. + pub fn from_context( + session_target: Option, + context: &AuthContext, + ) -> Result { + let lease = context + .authorization_lease() + .ok_or(AuthorizationInvalidationRuntimeError::InvalidDependencies)?; + let (binding, delegated_owner, delegated_relationship) = + match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => { + if lease.owner_pubkey().is_some() + || binding.bound_pubkey() != context.pubkey() + || context.agent_owner_pubkey().is_some() + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + (binding, None, None) + } + FederatedAuthorization::Delegated { owner, .. } => { + let owner_key = owner.bound_pubkey(); + let delegation = context + .nostr() + .verified_delegation() + .ok_or(AuthorizationInvalidationRuntimeError::InvalidDependencies)?; + if lease.owner_pubkey() != Some(owner_key) + || context.agent_owner_pubkey() != Some(owner_key) + || delegation.owner_pubkey() != owner_key + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + ( + owner, + Some(owner_key), + Some(( + delegation.relationship_id().as_uuid(), + delegation.relationship_revision().get(), + )), + ) + } + FederatedAuthorization::NotRequired => { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + }; + if !matches!( + context.federated_policy().requirement(), + FederatedIdentityRequirement::Required(_) + ) || context.tenant().community() != lease.authorization_domain() + || context.federated_policy().authorization_domain() != lease.authorization_domain() + || context.transport() != lease.transport() + || context.pubkey() != lease.actor_pubkey() + || context.correlation_id() != lease.correlation_id() + || binding.authorization_domain() != lease.authorization_domain() + || binding.binding_id() != lease.binding_id() + || binding.binding_version() != lease.binding_version() + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + let actor = lease.actor_pubkey().to_bytes(); + let mut selectors = vec![ + AuthorizationSelector::principal( + binding.principal().issuer(), + binding.principal().subject(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::nostr_key(actor), + AuthorizationSelector::binding(lease.binding_id(), lease.binding_version().get()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(lease.policy_version().as_str()) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } + if let Some(owner) = delegated_owner { + selectors.extend(delegated_owner_selectors(owner.to_bytes())); + } + if let Some((relationship_id, relationship_revision)) = delegated_relationship { + selectors.push( + AuthorizationSelector::delegated_relationship( + relationship_id, + relationship_revision, + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ); + } + Self::from_selectors(lease.authorization_domain(), selectors) + } + + fn from_selectors( + community_id: CommunityId, + selectors: Vec, + ) -> Result { + if selectors.is_empty() { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + let mut dependencies = selectors + .into_iter() + .map(|selector| Dependency { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + binding_version: selector.binding_version_floor(), + }) + .collect::>(); + dependencies.sort_by_key(|dependency| (dependency.kind, dependency.fingerprint)); + dependencies.dedup(); + Ok(Self { + community_id, + selectors: dependencies, + }) + } + + /// Server-resolved domain represented by the dependencies. + pub const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Exact durable selector set carried into a PostgreSQL commit fence. + pub fn commit_dependencies( + &self, + ) -> Result, super::executor::AuthorizationExecutionError> + { + self.selectors + .iter() + .map(|dependency| { + super::executor::CommitDependency::from_trusted_runtime( + dependency.kind, + dependency.fingerprint, + dependency.binding_version, + ) + }) + .collect() + } +} + +impl fmt::Debug for AuthorizationDependencies { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationDependencies") + .field("community_id", &"[redacted]") + .field("selector_count", &self.selectors.len()) + .finish() + } +} + +type FloorKey = (AuthorizationSelectorKind, [u8; 32]); + +#[derive(Default)] +struct CachedDomain { + ready: bool, + generation: u64, + floors: BTreeMap, + last_success: Option, +} + +#[derive(Default)] +struct DomainState { + cached: RwLock, + reconcile_lock: Mutex<()>, +} + +#[derive(Clone)] +struct Registration { + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, + cancellation: Option, +} + +#[async_trait] +trait InvalidationStore: Send + Sync { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result; + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result; + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result; +} + +struct DbInvalidationStore(Db); + +#[async_trait] +impl InvalidationStore for DbInvalidationStore { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + self.0 + .apply_authorization_invalidation(community_id, request) + .await + } + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result { + self.0 + .authorization_invalidation_snapshot(community_id) + .await + } + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + self.0 + .authorization_invalidation_delta(community_id, after_generation) + .await + } +} + +struct RuntimeInner { + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + domains: DashMap>, + registrations: DashMap, + shutdown: CancellationToken, + healthy: AtomicBool, + restore: Option>, +} + +/// Cloneable fail-closed invalidation and reconciliation runtime. +#[derive(Clone)] +pub struct AuthorizationInvalidationRuntime { + inner: Arc, +} + +impl fmt::Debug for AuthorizationInvalidationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationInvalidationRuntime") + .field("domains", &self.inner.domains.len()) + .field("registrations", &self.inner.registrations.len()) + .finish() + } +} + +impl AuthorizationInvalidationRuntime { + /// Build a runtime backed by the writer database and provider-neutral Redis hints. + pub fn new( + db: Db, + pubsub: Arc, + config: AuthorizationInvalidationConfig, + ) -> Self { + Self::with_store(Arc::new(DbInvalidationStore(db)), Some(pubsub), config) + } + + /// Build a production runtime whose invalidation commits participate in + /// the independent restore witness protocol. + pub fn new_with_restore( + db: Db, + pubsub: Arc, + config: AuthorizationInvalidationConfig, + restore: Arc, + ) -> Self { + Self::with_store_and_restore( + Arc::new(DbInvalidationStore(db)), + Some(pubsub), + config, + Some(restore), + ) + } + + fn with_store( + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + ) -> Self { + Self::with_store_and_restore(store, pubsub, config, None) + } + + fn with_store_and_restore( + store: Arc, + pubsub: Option>, + config: AuthorizationInvalidationConfig, + restore: Option>, + ) -> Self { + Self { + inner: Arc::new(RuntimeInner { + store, + pubsub, + config, + domains: DashMap::new(), + registrations: DashMap::new(), + shutdown: CancellationToken::new(), + healthy: AtomicBool::new(true), + restore, + }), + } + } + + fn domain_state(&self, community_id: CommunityId) -> Arc { + self.inner + .domains + .entry(community_id) + .or_insert_with(|| Arc::new(DomainState::default())) + .clone() + } + + /// Install full durable snapshots before marking protected domains ready. + pub async fn initialize_domains( + &self, + domains: impl IntoIterator, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + for community_id in domains { + self.reconcile(community_id, true).await?; + } + Ok(()) + } + + /// Whether a domain has a fresh successful authority snapshot. + pub fn is_ready(&self, community_id: CommunityId) -> bool { + self.inner.check_ready(community_id).is_ok() + } + + /// Capture the durable generation immediately before provider evaluation. + /// A new domain is synchronously bootstrapped from the writer database. + pub async fn capture_before_evaluation( + &self, + community_id: CommunityId, + ) -> Result { + if !self.inner.domains.contains_key(&community_id) { + self.reconcile(community_id, true).await?; + } + let generation = self.inner.check_ready(community_id)?; + Ok(AuthorizationEvaluationFence { + community_id, + generation, + }) + } + + /// Recheck a read-only evaluation fence without registering authority. + /// This is the mutation-free Shadow/VerifyOnly path. + pub fn recheck_evaluation( + &self, + fence: AuthorizationEvaluationFence, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let current = self.inner.check_ready(fence.community_id)?; + if current != fence.generation { + return Err(AuthorizationInvalidationRuntimeError::Invalidated); + } + Ok(()) + } + + /// Register finalized authority and immediately recheck the pre-evaluation + /// fence, closing the race between provider evaluation and registration. + pub fn observe_authority( + &self, + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, + cancellation: Option, + ) -> Result { + if fence.community_id != dependencies.community_id { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + self.inner.check_observation(fence, &dependencies)?; + let registration_id = Uuid::new_v4(); + self.inner.registrations.insert( + registration_id, + Registration { + fence, + dependencies: dependencies.clone(), + cancellation, + }, + ); + if let Err(error) = self.inner.check_observation(fence, &dependencies) { + if let Some((_, registration)) = self.inner.registrations.remove(®istration_id) { + if let Some(token) = registration.cancellation { + token.cancel(); + } + } + return Err(error); + } + Ok(AuthorizationObserver { + inner: Arc::downgrade(&self.inner), + registration_id, + fence, + dependencies, + }) + } + + /// Force a full writer-database reconcile for one domain. + pub async fn reconcile_domain( + &self, + community_id: CommunityId, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.reconcile(community_id, true).await + } + + /// Reconcile local state and publish a hint after an already-durable commit. + pub async fn publish_committed( + &self, + receipt: AuthorizationInvalidationReceipt, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.reconcile(receipt.community_id, false).await?; + let Some(pubsub) = &self.inner.pubsub else { + return Ok(()); + }; + pubsub + .publish_authorization_invalidation( + receipt.community_id, + AuthorizationInvalidationHint::current(receipt.generation), + ) + .await + .map_err(|_| AuthorizationInvalidationRuntimeError::HintUnavailable)?; + Ok(()) + } + + /// Durably fence reversible admission loss, reconcile locally, and then + /// advertise the committed generation to other nodes. + /// + /// The tenant is the row-zero server-resolved domain boundary. No domain + /// value is accepted from the provider event itself. + pub async fn apply_admission_loss( + &self, + tenant: &TenantContext, + event: &AuthorizationAdmissionLoss, + ) -> Result { + let revocation_started = Instant::now(); + self.inner.check_health()?; + let community_id = tenant.community(); + let request_fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &event.request, + ); + let restore = match &self.inner.restore { + Some(runtime) => Some( + runtime + .begin(community_id, event.request.event_id(), request_fingerprint) + .await + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?, + ), + None => None, + }; + let result = match self.inner.store.apply(community_id, &event.request).await { + Ok(result) => result, + Err(error) => { + if let Some(restore) = restore { + let _ = restore.abort().await; + } + tracing::warn!(%error, "authorization admission-loss commit failed closed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + }; + if let Some(restore) = restore { + // Both variants prove the exact durable receipt. `AlreadyApplied` + // is a replay, not a rollback, so it must advance the Pending + // witness to Committed rather than attempting to abort it. + if let Err(error) = restore + .commit_invalidation(result.receipt().generation) + .await + { + tracing::warn!(%error, "authorization invalidation witness failed closed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + } + let receipt = result.receipt(); + self.publish_committed(receipt).await?; + // This is a conservative upper bound: the local cancellation happens + // during `publish_committed`'s reconcile, before the optional hint is + // published. No selector, principal, token, or private claim is a label. + metrics::histogram!("buzz_authorization_revocation_to_enforcement_seconds") + .record(revocation_started.elapsed().as_secs_f64()); + Ok(receipt) + } + + /// Poll every known domain once. Lost Redis hints converge here. + pub async fn poll_once(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + let domains = self + .inner + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + let mut first_error = None; + for community_id in domains { + if let Err(error) = self.reconcile(community_id, false).await { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) + } + + /// Run periodic reconciliation and optional Redis-hint consumption until shutdown. + /// Durable polling remains active when Redis is absent. + pub async fn run(&self) { + let mut hints = self + .inner + .pubsub + .as_ref() + .map(|pubsub| pubsub.subscribe_authorization_invalidations()); + let mut interval = tokio::time::interval(self.inner.config.poll_interval()); + loop { + tokio::select! { + _ = self.inner.shutdown.cancelled() => return, + _ = interval.tick() => { + if let Err(error) = self.poll_once().await { + tracing::warn!(%error, "authorization invalidation poll failed closed"); + } + } + received = next_hint(&mut hints) => match received { + Ok(scoped) => { + if let Err(error) = self.handle_hint(scoped).await { + tracing::warn!(%error, "authorization invalidation hint reconcile failed closed"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if let Err(error) = self.reconcile_all(true).await { + tracing::warn!(%error, "authorization invalidation lag recovery failed closed"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + self.inner.mark_all_failed(); + return; + } + } + } + } + } + + /// Stop the runtime loop. Existing observers fail closed once the runtime drops. + pub fn shutdown(&self) { + self.inner.shutdown.cancel(); + } + + /// Immediately make every installed domain unavailable. + /// + /// Production worker supervision calls this whenever a required worker + /// exits, including a panic. Existing and future Enforce observations then + /// fail closed instead of continuing with an unsupervised cache. + pub fn fail_closed(&self) { + self.inner.healthy.store(false, Ordering::SeqCst); + self.inner.mark_all_failed(); + } + + async fn handle_hint( + &self, + scoped: ScopedAuthorizationInvalidationHint, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let full = scoped.hint.wire_version != AUTHORIZATION_INVALIDATION_WIRE_VERSION; + if !full { + let current = self + .inner + .generation(scoped.community_id) + .unwrap_or_default(); + if scoped.hint.generation <= current { + return Ok(()); + } + } + self.reconcile(scoped.community_id, full).await + } + + async fn reconcile_all(&self, full: bool) -> Result<(), AuthorizationInvalidationRuntimeError> { + let domains = self + .inner + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + let mut first_error = None; + for community_id in domains { + if let Err(error) = self.reconcile(community_id, full).await { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) + } + + async fn reconcile( + &self, + community_id: CommunityId, + full: bool, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.inner.check_health()?; + let state = self.domain_state(community_id); + let _guard = state.reconcile_lock.lock().await; + self.inner.check_health()?; + let current = self.inner.generation(community_id).unwrap_or_default(); + let result = if full { + self.inner.store.snapshot(community_id).await + } else { + self.inner.store.delta(community_id, current).await + }; + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::warn!(%error, "authorization invalidation writer read failed"); + self.inner.mark_failed(community_id); + return Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable); + } + }; + self.inner.install_snapshot(snapshot, full)?; + self.inner.cancel_invalid(community_id); + Ok(()) + } +} + +fn delegated_owner_selectors(owner: [u8; 32]) -> [AuthorizationSelector; 2] { + [ + AuthorizationSelector::nostr_key(owner), + AuthorizationSelector::delegated_owner(owner), + ] +} + +async fn next_hint( + hints: &mut Option>, +) -> Result { + match hints { + Some(receiver) => receiver.recv().await, + None => std::future::pending().await, + } +} + +impl RuntimeInner { + fn check_health(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + if self.healthy.load(Ordering::SeqCst) { + Ok(()) + } else { + Err(AuthorizationInvalidationRuntimeError::NotReady) + } + } + + fn generation(&self, community_id: CommunityId) -> Option { + let state = self.domains.get(&community_id)?; + state.cached.read().ok().map(|cached| cached.generation) + } + + fn check_ready( + &self, + community_id: CommunityId, + ) -> Result { + self.check_health()?; + let state = self + .domains + .get(&community_id) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let cached = state + .cached + .read() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if !cached.ready { + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + let last_success = cached + .last_success + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + if Instant::now().saturating_duration_since(last_success) > self.config.max_staleness() { + return Err(AuthorizationInvalidationRuntimeError::Stale); + } + Ok(cached.generation) + } + + fn check_observation( + &self, + fence: AuthorizationEvaluationFence, + dependencies: &AuthorizationDependencies, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + self.check_health()?; + let state = self + .domains + .get(&fence.community_id) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let cached = state + .cached + .read() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if !cached.ready { + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + let last_success = cached + .last_success + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + if Instant::now().saturating_duration_since(last_success) > self.config.max_staleness() { + return Err(AuthorizationInvalidationRuntimeError::Stale); + } + if cached.generation < fence.generation { + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + for dependency in &dependencies.selectors { + let key = (dependency.kind, dependency.fingerprint); + let Some(floor) = cached.floors.get(&key) else { + continue; + }; + let binding_denied = match (floor.binding_version_floor, dependency.binding_version) { + (Some(floor_version), Some(version)) => version <= floor_version, + _ => false, + }; + if floor.sticky_deny || binding_denied || floor.generation > fence.generation { + return Err(AuthorizationInvalidationRuntimeError::Invalidated); + } + } + Ok(()) + } + + fn install_snapshot( + &self, + snapshot: AuthorizationInvalidationSnapshot, + full: bool, + ) -> Result<(), AuthorizationInvalidationRuntimeError> { + let state = self + .domains + .get(&snapshot.community_id) + .map(|entry| entry.clone()) + .ok_or(AuthorizationInvalidationRuntimeError::NotReady)?; + let mut cached = state + .cached + .write() + .map_err(|_| AuthorizationInvalidationRuntimeError::AuthorityUnavailable)?; + if self.check_health().is_err() { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::NotReady); + } + if snapshot.generation < cached.generation + || snapshot + .floors + .iter() + .any(|floor| floor.generation > snapshot.generation) + { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + let incoming = snapshot + .floors + .into_iter() + .map(|floor| ((floor.kind, floor.fingerprint), floor)) + .collect::>(); + if full + && cached.ready + && cached.floors.iter().any(|(key, existing)| { + incoming.get(key).is_none_or(|next| { + next.generation < existing.generation + || (existing.sticky_deny && !next.sticky_deny) + || next.binding_version_floor < existing.binding_version_floor + }) + }) + { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + if full { + cached.floors = incoming; + } else { + for (key, floor) in incoming { + if cached.floors.get(&key).is_some_and(|existing| { + floor.generation < existing.generation + || (existing.sticky_deny && !floor.sticky_deny) + || floor.binding_version_floor < existing.binding_version_floor + }) { + cached.ready = false; + return Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed); + } + cached.floors.insert(key, floor); + } + } + cached.generation = snapshot.generation; + cached.last_success = Some(Instant::now()); + cached.ready = true; + Ok(()) + } + + fn mark_failed(&self, community_id: CommunityId) { + if let Some(state) = self.domains.get(&community_id) { + if let Ok(mut cached) = state.cached.write() { + cached.ready = false; + } + } + self.cancel_invalid(community_id); + } + + fn mark_all_failed(&self) { + let domains = self + .domains + .iter() + .map(|entry| *entry.key()) + .collect::>(); + for community_id in domains { + self.mark_failed(community_id); + } + } + + fn cancel_invalid(&self, community_id: CommunityId) { + let registrations = self + .registrations + .iter() + .filter(|entry| entry.value().fence.community_id == community_id) + .map(|entry| (*entry.key(), entry.value().clone())) + .collect::>(); + for (registration_id, registration) in registrations { + if self + .check_observation(registration.fence, ®istration.dependencies) + .is_err() + { + if let Some((_, removed)) = self.registrations.remove(®istration_id) { + if let Some(token) = removed.cancellation { + token.cancel(); + } + } + } + } + } +} + +/// Registered authority observer used for both pre-use and pre-commit checks. +pub struct AuthorizationObserver { + inner: Weak, + registration_id: Uuid, + fence: AuthorizationEvaluationFence, + dependencies: AuthorizationDependencies, +} + +impl AuthorizationObserver { + /// Fail closed unless the captured authority is still fresh and uninvalidated. + /// Call immediately before each protected use and again before commit. + pub fn recheck(&self) -> Result<(), AuthorizationInvalidationRuntimeError> { + let inner = self + .inner + .upgrade() + .ok_or(AuthorizationInvalidationRuntimeError::RuntimeStopped)?; + inner.check_observation(self.fence, &self.dependencies) + } + + /// Pre-evaluation fence retained by this observer. + pub const fn fence(&self) -> AuthorizationEvaluationFence { + self.fence + } + + /// Seal the durable generation and dependencies for transaction-owned use. + pub fn commit_fence( + &self, + ) -> Result< + super::executor::AuthorizationCommitFence, + super::executor::AuthorizationExecutionError, + > { + super::executor::AuthorizationCommitFence::from_trusted_runtime( + self.fence.generation(), + self.dependencies.commit_dependencies()?, + ) + } +} + +impl fmt::Debug for AuthorizationObserver { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationObserver") + .field("registration_id", &"[redacted]") + .field("fence", &self.fence) + .field("dependencies", &self.dependencies) + .finish() + } +} + +impl Drop for AuthorizationObserver { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + inner.registrations.remove(&self.registration_id); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + #[derive(Default)] + struct FakeStore { + snapshots: std::sync::Mutex>, + receipts: std::sync::Mutex< + HashMap< + (CommunityId, Uuid), + ( + AuthorizationInvalidationRequest, + AuthorizationInvalidationReceipt, + ), + >, + >, + fail: AtomicBool, + } + + impl FakeStore { + fn set(&self, snapshot: AuthorizationInvalidationSnapshot) { + self.snapshots + .lock() + .expect("fake store lock") + .insert(snapshot.community_id, snapshot); + } + } + + #[async_trait] + impl InvalidationStore for FakeStore { + async fn apply( + &self, + community_id: CommunityId, + request: &AuthorizationInvalidationRequest, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(DbError::InvalidData("synthetic failure".into())); + } + let key = (community_id, request.event_id()); + let mut receipts = self.receipts.lock().expect("fake receipt lock"); + if let Some((stored, receipt)) = receipts.get(&key) { + if stored != request { + return Err(DbError::InvalidData("synthetic event ID collision".into())); + } + return Ok(AuthorizationInvalidationResult::AlreadyApplied(*receipt)); + } + + let mut snapshots = self.snapshots.lock().expect("fake store lock"); + let snapshot = + snapshots + .entry(community_id) + .or_insert(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + snapshot.generation = snapshot + .generation + .checked_add(1) + .ok_or_else(|| DbError::InvalidData("synthetic generation exhausted".into()))?; + for entry in request.entries() { + let selector = entry.selector(); + let binding_version_floor = selector.binding_version_floor(); + if let Some(floor) = snapshot.floors.iter_mut().find(|floor| { + floor.kind == selector.kind() && floor.fingerprint == selector.fingerprint() + }) { + floor.generation = snapshot.generation; + floor.sticky_deny |= matches!( + entry.effect(), + buzz_db::authorization_invalidation::AuthorizationInvalidationEffect::StickyDeny + ); + floor.binding_version_floor = + match (floor.binding_version_floor, binding_version_floor) { + (Some(current), Some(candidate)) => Some(current.max(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + } else { + snapshot.floors.push(AuthorizationInvalidationFloor { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + generation: snapshot.generation, + sticky_deny: matches!( + entry.effect(), + buzz_db::authorization_invalidation::AuthorizationInvalidationEffect::StickyDeny + ), + binding_version_floor, + }); + } + } + let receipt = AuthorizationInvalidationReceipt { + community_id, + event_id: request.event_id(), + generation: snapshot.generation, + }; + receipts.insert(key, (request.clone(), receipt)); + Ok(AuthorizationInvalidationResult::Applied(receipt)) + } + + async fn snapshot( + &self, + community_id: CommunityId, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(DbError::InvalidData("synthetic failure".into())); + } + Ok(self + .snapshots + .lock() + .expect("fake store lock") + .get(&community_id) + .cloned() + .unwrap_or(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + })) + } + + async fn delta( + &self, + community_id: CommunityId, + after_generation: u64, + ) -> Result { + let mut snapshot = self.snapshot(community_id).await?; + snapshot + .floors + .retain(|floor| floor.generation > after_generation); + Ok(snapshot) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn runtime(store: Arc) -> AuthorizationInvalidationRuntime { + AuthorizationInvalidationRuntime::with_store( + store, + None, + AuthorizationInvalidationConfig::new(Duration::from_millis(5), Duration::from_secs(10)) + .expect("valid config"), + ) + } + + fn dependencies( + community_id: CommunityId, + selector: AuthorizationSelector, + ) -> AuthorizationDependencies { + dependencies_for(community_id, vec![selector]) + } + + fn dependencies_for( + community_id: CommunityId, + selectors: Vec, + ) -> AuthorizationDependencies { + let mut all = vec![AuthorizationSelector::domain()]; + all.extend(selectors); + AuthorizationDependencies::from_selectors(community_id, all).expect("valid dependencies") + } + + async fn observe( + runtime: &AuthorizationInvalidationRuntime, + community_id: CommunityId, + selectors: Vec, + cancellation: CancellationToken, + ) -> AuthorizationObserver { + let fence = runtime + .capture_before_evaluation(community_id) + .await + .expect("capture evaluation fence"); + runtime + .observe_authority( + fence, + dependencies_for(community_id, selectors), + Some(cancellation), + ) + .expect("observe authority") + } + + fn floor( + selector: &AuthorizationSelector, + generation: u64, + sticky_deny: bool, + ) -> AuthorizationInvalidationFloor { + AuthorizationInvalidationFloor { + kind: selector.kind(), + fingerprint: selector.fingerprint(), + generation, + sticky_deny, + binding_version_floor: selector.binding_version_floor(), + } + } + + #[tokio::test] + async fn two_nodes_converge_after_lost_reordered_and_replayed_hints() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(1); + let selector = AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()).expect("valid session"), + ); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node_a = runtime(store.clone()); + let node_b = runtime(store.clone()); + node_a + .initialize_domains([community_id]) + .await + .expect("node A starts"); + node_b + .initialize_domains([community_id]) + .await + .expect("node B starts"); + let fence = node_b + .capture_before_evaluation(community_id) + .await + .expect("capture fence"); + let cancellation = CancellationToken::new(); + let observer = node_b + .observe_authority( + fence, + dependencies(community_id, selector.clone()), + Some(cancellation.clone()), + ) + .expect("observe authority"); + + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 2, + floors: vec![floor(&selector, 2, true)], + }); + node_a.poll_once().await.expect("node A reconciles"); + assert!( + observer.recheck().is_ok(), + "lost hint has not reached node B yet" + ); + node_b.poll_once().await.expect("poll heals lost hint"); + assert_eq!( + observer.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert!(cancellation.is_cancelled()); + + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("delayed hint is harmless"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(2), + }) + .await + .expect("replayed hint is harmless"); + assert_eq!(node_b.inner.generation(community_id), Some(2)); + } + + #[tokio::test] + async fn admission_loss_is_idempotent_and_cancels_two_nodes_before_readmission() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(7); + let tenant = TenantContext::resolved(community_id, "admission-loss.example"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node_a = runtime(store.clone()); + let node_b = runtime(store.clone()); + node_a + .initialize_domains([community_id]) + .await + .expect("node A starts"); + node_b + .initialize_domains([community_id]) + .await + .expect("node B starts"); + + let principal = + AuthorizationSelector::principal("issuer.example", "subject").expect("principal"); + let actor = AuthorizationSelector::nostr_key([7_u8; 32]); + let owner = AuthorizationSelector::delegated_owner([8_u8; 32]); + let direct_dependencies = vec![principal.clone(), actor.clone()]; + let delegated_dependencies = vec![principal.clone(), owner.clone()]; + let a_direct_cancel = CancellationToken::new(); + let a_delegated_cancel = CancellationToken::new(); + let b_direct_cancel = CancellationToken::new(); + let b_delegated_cancel = CancellationToken::new(); + let a_direct = observe( + &node_a, + community_id, + direct_dependencies.clone(), + a_direct_cancel.clone(), + ) + .await; + let a_delegated = observe( + &node_a, + community_id, + delegated_dependencies.clone(), + a_delegated_cancel.clone(), + ) + .await; + let b_direct = observe( + &node_b, + community_id, + direct_dependencies.clone(), + b_direct_cancel.clone(), + ) + .await; + let b_delegated = observe( + &node_b, + community_id, + delegated_dependencies.clone(), + b_delegated_cancel.clone(), + ) + .await; + + let event_id = Uuid::new_v4(); + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![principal.clone(), actor.clone(), owner.clone()], + ) + .expect("valid provider-neutral admission loss"); + let first = node_a + .apply_admission_loss(&tenant, &event) + .await + .expect("admission loss commits"); + assert_eq!(first.event_id, event_id); + assert_eq!(first.generation, 1); + assert!(a_direct_cancel.is_cancelled()); + assert!(a_delegated_cancel.is_cancelled()); + assert_eq!( + a_direct.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert_eq!( + a_delegated.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert!(!b_direct_cancel.is_cancelled()); + assert!(!b_delegated_cancel.is_cancelled()); + + let duplicate = node_a + .apply_admission_loss(&tenant, &event) + .await + .expect("duplicate event reconciles idempotently"); + assert_eq!(duplicate, first); + assert_eq!(store.snapshot(community_id).await.unwrap().generation, 1); + + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("current hint reconciles"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(0), + }) + .await + .expect("reordered delayed hint is harmless"); + node_b + .handle_hint(ScopedAuthorizationInvalidationHint { + community_id, + hint: AuthorizationInvalidationHint::current(1), + }) + .await + .expect("replayed hint is harmless"); + assert!(b_direct_cancel.is_cancelled()); + assert!(b_delegated_cancel.is_cancelled()); + assert_eq!( + b_direct.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + assert_eq!( + b_delegated.recheck(), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + ); + + for (node, selectors) in [ + (&node_a, direct_dependencies), + (&node_b, delegated_dependencies), + ] { + let fence = node + .capture_before_evaluation(community_id) + .await + .expect("capture post-loss generation"); + assert_eq!(fence.generation(), 1); + let fresh = + node.observe_authority(fence, dependencies_for(community_id, selectors), None); + assert!( + fresh.is_ok(), + "fresh admission at the committed generation is permitted" + ); + } + } + + #[tokio::test] + #[ignore = "requires migrated Postgres and S3-compatible object storage"] + async fn restore_witnessed_admission_loss_replay_is_idempotent_and_fingerprint_bound() { + 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 = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("test migrations"); + let db = Db::from_pool(pool.clone()); + 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!( + "restore-invalidation-{}.example", + Uuid::new_v4().simple() + )) + .execute(&pool) + .await + .expect("test community"); + + let endpoint = std::env::var("BUZZ_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_owned()); + let access_key = + std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_owned()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_owned()); + let bucket = std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-git".to_owned()); + let store = crate::api::git::store::GitStore::new( + &endpoint, + &access_key, + &secret_key, + &bucket, + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("test object store"); + let bootstrap_id = Uuid::new_v4(); + super::super::restore::RestoreProtectionRuntime::provision_domain( + &db, + &store, + community_id, + bootstrap_id, + ) + .await + .expect("provision restore witness"); + let restore = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("initialize restore witness"); + let tenant = TenantContext::resolved(community_id, "restore-invalidation.example"); + let event_id = Uuid::new_v4(); + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal("issuer.example", "subject-a").expect("principal"), + AuthorizationSelector::nostr_key([41_u8; 32]), + ], + ) + .expect("valid admission loss"); + let fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &event.request, + ); + + // Replica A writes Pending and commits PostgreSQL. Replica B then + // recovers the pending witness and replays the exact database effect + // before A attempts its now-stale ETag CAS. + let replica_a = restore + .begin(community_id, event_id, fingerprint) + .await + .expect("replica A begins invalidation"); + let first = db + .apply_authorization_invalidation(community_id, &event.request) + .await + .expect("database effect commits") + .receipt(); + let restore_b = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("replica B recovers replica A pending witness"); + let replica_b = restore_b + .begin(community_id, event_id, fingerprint) + .await + .expect("replica B recognizes exact durable replay"); + let replay = db + .apply_authorization_invalidation(community_id, &event.request) + .await + .expect("replica B replay converges"); + assert!(!replay.committed_now()); + replica_b + .commit_invalidation(first.generation) + .await + .expect("replica B observes committed witness"); + replica_a + .commit_invalidation(first.generation) + .await + .expect("replica A stale CAS converges idempotently"); + + let config = + AuthorizationInvalidationConfig::new(Duration::from_millis(5), Duration::from_secs(10)) + .expect("valid config"); + let runtime = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + config, + Some(restore_b), + ); + runtime + .initialize_domains([community_id]) + .await + .expect("initialize invalidation domain"); + let (duplicate_a, duplicate_b) = tokio::join!( + runtime.apply_admission_loss(&tenant, &event), + runtime.apply_admission_loss(&tenant, &event), + ); + assert_eq!(duplicate_a.expect("first replay"), first); + assert_eq!(duplicate_b.expect("concurrent replay"), first); + assert_eq!(first.generation, 1); + + let restore_after_replay = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("replay leaves a committed checkpoint"); + let restarted = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + AuthorizationInvalidationConfig::default(), + Some(restore_after_replay), + ); + restarted + .initialize_domains([community_id]) + .await + .expect("restart after replay"); + + let conflicting = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal("issuer.example", "subject-b").expect("principal"), + ], + ) + .expect("valid conflicting request"); + assert_eq!( + restarted.apply_admission_loss(&tenant, &conflicting).await, + Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable) + ); + let restore_after_conflict = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("fingerprint conflict cannot poison the checkpoint"); + let after_conflict = AuthorizationInvalidationRuntime::with_store_and_restore( + Arc::new(DbInvalidationStore(db.clone())), + None, + AuthorizationInvalidationConfig::default(), + Some(Arc::clone(&restore_after_conflict)), + ); + after_conflict + .initialize_domains([community_id]) + .await + .expect("restart after conflict"); + let next = AuthorizationAdmissionLoss::new( + Uuid::new_v4(), + vec![AuthorizationSelector::nostr_key([42_u8; 32])], + ) + .expect("valid next event"); + let next_receipt = after_conflict + .apply_admission_loss(&tenant, &next) + .await + .expect("later valid event commits"); + assert_eq!(next_receipt.generation, 2); + + // The generic witness commit used by audio reconciliation must have + // the same stale-CAS convergence property. Force a third durable + // operation through two independent restore runtimes, but commit the + // witness with `commit` rather than the invalidation specialization. + let generic = AuthorizationAdmissionLoss::new( + Uuid::new_v4(), + vec![AuthorizationSelector::nostr_key([43_u8; 32])], + ) + .expect("valid generic witness event"); + let generic_fingerprint = + buzz_db::authorization_invalidation::authorization_invalidation_request_fingerprint( + community_id, + &generic.request, + ); + let generic_a = restore_after_conflict + .begin( + community_id, + generic.request.event_id(), + generic_fingerprint, + ) + .await + .expect("generic replica A begins"); + db.apply_authorization_invalidation(community_id, &generic.request) + .await + .expect("generic database effect commits"); + let generic_restore_b = super::super::restore::RestoreProtectionRuntime::initialize( + db.clone(), + store.clone(), + [(community_id, bootstrap_id)], + ) + .await + .expect("generic replica B recovers pending witness"); + let generic_b = generic_restore_b + .begin( + community_id, + generic.request.event_id(), + generic_fingerprint, + ) + .await + .expect("generic replica B recognizes durable replay"); + generic_b.commit().await.expect("replica B commits witness"); + generic_a + .commit() + .await + .expect("replica A stale generic CAS converges"); + } + + #[tokio::test] + async fn admission_loss_uses_only_the_server_resolved_tenant_domain() { + let store = Arc::new(FakeStore::default()); + let selected_domain = domain(8); + let other_domain = domain(9); + for community_id in [selected_domain, other_domain] { + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + } + let runtime = runtime(store.clone()); + runtime + .initialize_domains([selected_domain, other_domain]) + .await + .expect("both domains start"); + let selector = AuthorizationSelector::nostr_key([9_u8; 32]); + let other_cancel = CancellationToken::new(); + let other_observer = observe( + &runtime, + other_domain, + vec![selector.clone()], + other_cancel.clone(), + ) + .await; + let event = AuthorizationAdmissionLoss::new(Uuid::new_v4(), vec![selector]) + .expect("valid admission loss"); + let tenant = TenantContext::resolved(selected_domain, "selected.example"); + let receipt = runtime + .apply_admission_loss(&tenant, &event) + .await + .expect("selected domain commits"); + + assert_eq!(receipt.community_id, selected_domain); + assert_eq!(store.snapshot(selected_domain).await.unwrap().generation, 1); + assert_eq!(store.snapshot(other_domain).await.unwrap().generation, 0); + assert!(!other_cancel.is_cancelled()); + assert!(other_observer.recheck().is_ok()); + } + + #[tokio::test] + async fn restart_bootstraps_full_state_before_readiness() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(2); + let selector = AuthorizationSelector::policy_version("old").expect("valid policy"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 7, + floors: vec![floor(&selector, 7, true)], + }); + let restarted = runtime(store); + assert!(!restarted.is_ready(community_id)); + let fence = restarted + .capture_before_evaluation(community_id) + .await + .expect("startup snapshot loads"); + assert_eq!(fence.generation(), 7); + assert!(matches!( + restarted.observe_authority(fence, dependencies(community_id, selector), None,), + Err(AuthorizationInvalidationRuntimeError::Invalidated) + )); + } + + #[tokio::test] + async fn read_failure_denies_immediately_and_partition_heal_recovers() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(3); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + store.fail.store(true, Ordering::SeqCst); + assert_eq!( + node.poll_once().await, + Err(AuthorizationInvalidationRuntimeError::AuthorityUnavailable) + ); + assert!(!node.is_ready(community_id)); + store.fail.store(false, Ordering::SeqCst); + node.poll_once().await.expect("partition heals"); + assert!(node.is_ready(community_id)); + } + + #[tokio::test] + async fn durable_regression_never_restores_authority() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(4); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 4, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 3, + floors: Vec::new(), + }); + assert_eq!( + node.reconcile_domain(community_id).await, + Err(AuthorizationInvalidationRuntimeError::AuthorityRegressed) + ); + assert!(!node.is_ready(community_id)); + } + + #[test] + fn debug_output_redacts_selector_material() { + let community_id = domain(5); + let private_policy = "private-policy-version"; + let dependencies = dependencies( + community_id, + AuthorizationSelector::policy_version(private_policy).expect("valid policy"), + ); + assert!(!format!("{dependencies:?}").contains(private_policy)); + } + + #[test] + fn admission_loss_rejects_unsafe_selectors_and_redacts_private_material() { + let event_id = Uuid::new_v4(); + let private_issuer = "private-issuer.example"; + let event = AuthorizationAdmissionLoss::new( + event_id, + vec![ + AuthorizationSelector::principal(private_issuer, "private-subject") + .expect("valid principal"), + AuthorizationSelector::nostr_key([3_u8; 32]), + AuthorizationSelector::delegated_owner([4_u8; 32]), + ], + ) + .expect("safe admission-loss selectors"); + let debug = format!("{event:?}"); + assert!(!debug.contains(private_issuer)); + assert!(!debug.contains(&event_id.to_string())); + + for selector in [ + AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version("private-policy").expect("valid policy"), + ] { + assert_eq!( + AuthorizationAdmissionLoss::new(Uuid::new_v4(), vec![selector]), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + } + assert_eq!( + AuthorizationAdmissionLoss::new(Uuid::new_v4(), Vec::new()), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + assert_eq!( + AuthorizationAdmissionLoss::new( + Uuid::nil(), + vec![AuthorizationSelector::nostr_key([5_u8; 32])], + ), + Err(AuthorizationInvalidationRuntimeError::InvalidAdmissionLoss) + ); + } + + #[test] + fn delegated_owner_matches_generic_key_and_owner_selectors() { + let owner = [7_u8; 32]; + let selectors = delegated_owner_selectors(owner); + assert_eq!(selectors[0].kind(), AuthorizationSelectorKind::NostrKey); + assert_eq!( + selectors[1].kind(), + AuthorizationSelectorKind::DelegatedOwner + ); + assert_eq!( + selectors[0].fingerprint(), + AuthorizationSelector::nostr_key(owner).fingerprint() + ); + assert_eq!( + selectors[1].fingerprint(), + AuthorizationSelector::delegated_owner(owner).fingerprint() + ); + } + + #[tokio::test] + async fn runtime_polls_durable_authority_without_redis() { + let store = Arc::new(FakeStore::default()); + let community_id = domain(6); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 0, + floors: Vec::new(), + }); + let node = runtime(store.clone()); + node.initialize_domains([community_id]) + .await + .expect("starts ready"); + let running = node.clone(); + let task = tokio::spawn(async move { running.run().await }); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 1, + floors: Vec::new(), + }); + tokio::time::timeout(Duration::from_secs(1), async { + while node.inner.generation(community_id) != Some(1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("polling converges without Redis"); + node.fail_closed(); + assert!(!node.is_ready(community_id)); + store.set(AuthorizationInvalidationSnapshot { + community_id, + generation: 2, + floors: Vec::new(), + }); + assert_eq!( + node.reconcile_domain(community_id).await, + Err(AuthorizationInvalidationRuntimeError::NotReady) + ); + assert!(!node.is_ready(community_id)); + node.shutdown(); + task.await.expect("runtime task exits"); + } + + #[test] + fn public_constructor_has_no_caller_supplied_principal_slot() { + fn assert_context_only_signature( + _constructor: fn( + Option, + &AuthContext, + ) -> Result< + AuthorizationDependencies, + AuthorizationInvalidationRuntimeError, + >, + ) { + } + + assert_context_only_signature(AuthorizationDependencies::from_context); + } +} 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..7292811390 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -0,0 +1,22 @@ +//! Provider-neutral runtime authorization seams. +//! +//! This commit registers the complete O4 module shape while implementing only +//! exact-domain provider selection, provider-evidence finalization, and bounded +//! leases. Transport adoption, invalidation, and client status remain separate +//! extension lanes. + +pub(crate) mod ephemeral; +/// Transaction-owned protected mutation execution and idempotency. +pub mod executor; +/// Exact-domain provider selection and authorization finalization. +pub mod finalization; +/// Durable provider-neutral invalidation, reconciliation, and use fences. +pub mod invalidation; +/// Disabled-by-default production runtime construction. +pub mod production; +/// Independent high-water protection against stale PostgreSQL restoration. +pub mod restore; +/// Reserved provider-neutral client-status extension seam. +pub mod status; +/// Reserved provider-neutral transport-adoption extension seam. +pub mod transport; diff --git a/crates/buzz-relay/src/authorization_runtime/production.rs b/crates/buzz-relay/src/authorization_runtime/production.rs new file mode 100644 index 0000000000..36181b43f7 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/production.rs @@ -0,0 +1,1457 @@ +//! Disabled-by-default production construction for protected authorization. +//! +//! Configuration names exact authorization domains. There is no default +//! domain, provider fallback, request-selected profile, or implicit Enforce. + +use std::{collections::HashMap, env, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use buzz_auth::{ + resolve_current_federated_policy, AccessLeasePolicy, ActiveBindingResolution, + ApplicationLeaseLimit, AuthContextInput, AuthorizationClockSkew, AuthorizationOutcome, + AuthorizationProvider, BindingLeaseBound, BindingSource, CapabilitySet, EnrollmentMode, + FederatedAuthorization, LeaseVersion, ProviderTimeout, ResolvedFederatedPolicy, Scope, + SharedAuthorizationClock, SystemAuthorizationClock, VerificationStatusPolicy, + VerifiedEvidenceAdapter, +}; +use buzz_core::{CommunityId, TenantContext}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + finalization::{ + AuthorizationMode, DomainAuthorizationPolicy, DomainProviderSelector, + RelayAuthorizationFinalizer, RuntimeAuthorizationDisposition, + }, + invalidation::{ + AuthorizationDependencies, AuthorizationInvalidationConfig, + AuthorizationInvalidationRuntime, AuthorizationObserver, + }, + transport::{ + DomainTransportPolicy, LeaseCurrentState, LeaseCurrentStateError, + LeaseCurrentStateObserver, ProtectedAuthorizationResolver, ProtectedOperationRequest, + ProtectedResolution, ProtectedResolutionError, ProtectedTransportRuntime, + }, +}; + +const DOMAINS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_DOMAINS"; +const PROFILE_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_PROFILE"; +const LEASE_SECONDS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_LEASE_SECONDS"; +const RESTORE_BOOTSTRAPS_ENV: &str = "BUZZ_PROTECTED_AUTHORIZATION_RESTORE_BOOTSTRAPS"; +const MAX_AUDIO_RECONCILIATION_SWEEPS: usize = 8; + +/// Runtime plus its durable invalidation worker. +pub struct InstalledProtectedRuntime { + /// Exact-domain transport runtime. + pub transport: Arc, + /// Durable invalidation runtime initialized before transport installation. + pub invalidation: AuthorizationInvalidationRuntime, + /// Independent high-water witness verified before transport installation. + pub restore: Arc, + /// Whether any exact domain is authoritative Enforce. + pub enforce_enabled: bool, + /// Exact authoritative domains whose crash remnants may be reconciled. + pub enforcing_domains: Vec, + /// Enforce domains whose optional public projection requires reconciliation. + pub projection_domains: Vec, +} + +/// Result of the single production installation boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProtectedRuntimeInstallation { + /// No exact authorization domains were configured; legacy behavior remains. + Disabled, + /// Exact-domain transport, restore, and invalidation runtimes were installed. + Installed, +} + +/// Exact-domain provider registry supplied by the deployment adapter. +/// +/// The OSS runtime never invents a current-admission decision. Enforce +/// construction fails when a configured domain has no injected O2 provider. +#[derive(Default)] +pub struct ProductionProviderRegistry { + providers: HashMap>, +} + +impl ProductionProviderRegistry { + /// Build an exact registry, rejecting duplicate domain mappings. + pub fn new( + providers: impl IntoIterator)>, + ) -> Result { + let mut exact = HashMap::new(); + for (domain, provider) in providers { + if exact.insert(domain, provider).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + Ok(Self { providers: exact }) + } + + fn provider_for( + &self, + domain: CommunityId, + ) -> Result, ProductionRuntimeError> { + self.providers + .get(&domain) + .cloned() + .ok_or(ProductionRuntimeError::ProviderMissing) + } +} + +struct ProductionLeaseObserver { + invalidation: AuthorizationObserver, + current: LeaseCurrentState, +} + +impl LeaseCurrentStateObserver for ProductionLeaseObserver { + fn observe_current(&self) -> Result { + self.invalidation + .recheck() + .map_err(|_| LeaseCurrentStateError::Stale)?; + Ok(self.current.clone()) + } + + fn observe_commit_fence( + &self, + ) -> Result { + self.invalidation + .commit_fence() + .map_err(|_| LeaseCurrentStateError::Unavailable) + } +} + +struct ProductionResolver { + db: buzz_db::Db, + tenants: HashMap, + finalizer: RelayAuthorizationFinalizer, + invalidation: AuthorizationInvalidationRuntime, + clock: SharedAuthorizationClock, +} + +impl ProductionResolver { + fn tenant(&self, domain: CommunityId) -> Result<&TenantContext, ProtectedResolutionError> { + self.tenants + .get(&domain) + .ok_or(ProtectedResolutionError::new("configured_domain_missing")) + } + + async fn current_policy( + &self, + domain: CommunityId, + correlation_id: uuid::Uuid, + ) -> Result { + let now = self + .clock + .now() + .map_err(|_| ProtectedResolutionError::new("authorization_clock"))? + .unix_seconds(); + resolve_current_federated_policy( + &self.db.federated_authority_adapter(), + domain, + correlation_id, + now, + ) + .await + .map_err(|_| ProtectedResolutionError::new("federated_policy_unavailable")) + } + + async fn active_binding( + &self, + domain: CommunityId, + pubkey: nostr::PublicKey, + assertion: Option<&buzz_auth::VerifiedFederatedAssertion>, + ) -> Result { + let binding = self + .db + .get_active_identity_binding_by_pubkey(domain, pubkey.as_bytes()) + .await + .map_err(|_| ProtectedResolutionError::new("binding_unavailable"))? + .ok_or(ProtectedResolutionError::new("active_binding_required"))?; + let source = match binding.binding_provenance { + buzz_db::identity_binding::BindingProvenance::AttestedKey => BindingSource::AttestedKey, + buzz_db::identity_binding::BindingProvenance::Provisioned => BindingSource::Provisioned, + buzz_db::identity_binding::BindingProvenance::Tofu => BindingSource::Tofu, + }; + let expires_at = binding + .expires_at + .as_ref() + .map(|value| u64::try_from(value.timestamp())) + .transpose() + .map_err(|_| ProtectedResolutionError::new("binding_expiry_invalid"))?; + VerifiedEvidenceAdapter::new() + .active_binding_from_store( + domain, + domain, + binding.binding_id, + &binding.issuer, + &binding.uid, + pubkey, + binding.binding_version, + expires_at, + source, + ActiveBindingResolution::Existing, + assertion, + ) + .map_err(|_| ProtectedResolutionError::new("binding_evidence_mismatch")) + } + + async fn require_membership( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + let member = request + .owner_pubkey() + .unwrap_or_else(|| request.actor_pubkey()); + match self + .db + .is_relay_member(request.authorization_domain(), &member.to_hex()) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(ProtectedResolutionError::new("membership_required")), + Err(_) => Err(ProtectedResolutionError::new("membership_unavailable")), + } + } + + fn reseal_assertion( + &self, + assertion: &buzz_auth::VerifiedFederatedAssertion, + ) -> Result { + let now = self + .clock + .now() + .map_err(|_| ProtectedResolutionError::new("authorization_clock"))? + .unix_seconds(); + VerifiedEvidenceAdapter::new() + .federated_assertion_from_validated_claims( + assertion.authorization_domain(), + assertion.authorized_transport(), + assertion.principal().issuer(), + assertion.principal().subject(), + assertion.key_attestation().map(|key| key.pubkey()), + assertion.transport(), + assertion.not_before().map(|bound| bound.unix_seconds()), + assertion.expires_at().unix_seconds(), + now, + ) + .map_err(|_| ProtectedResolutionError::new("assertion_stale")) + } +} + +#[async_trait] +impl ProtectedAuthorizationResolver for ProductionResolver { + async fn observe( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + self.require_membership(request).await?; + let capabilities = CapabilitySet::single(request.capability()); + let federated_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let outcome = if let Some(owner) = request.owner_pubkey() { + let binding = self + .active_binding(request.authorization_domain(), owner, None) + .await?; + self.finalizer + .evaluate_delegated( + tenant, + request.verified_proof(), + &binding, + federated_policy, + capabilities, + request.correlation_id(), + ) + .await + } else { + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let _binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + self.finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + federated_policy, + capabilities, + request.correlation_id(), + ) + .await + } + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + if !matches!(outcome, AuthorizationOutcome::Allow(_)) { + return Err(ProtectedResolutionError::new("provider_denied")); + } + self.invalidation + .recheck_evaluation(fence) + .map_err(|_| ProtectedResolutionError::new("invalidation_race")) + } + + async fn present( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + if request.owner_pubkey().is_some() { + return Err(ProtectedResolutionError::new( + "client_status_requires_direct_binding", + )); + } + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + self.require_membership(request).await?; + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let snapshot = match self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + CapabilitySet::single(request.capability()), + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))? + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + _ => return Err(ProtectedResolutionError::new("provider_denied")), + }; + let binding_bound = BindingLeaseBound::new(&binding, snapshot.effective_until()) + .map_err(|_| ProtectedResolutionError::new("binding_bound_invalid"))?; + let authorization = FederatedAuthorization::Direct { + binding, + assertion: self.reseal_assertion(assertion)?, + }; + let access = VerifiedEvidenceAdapter::new() + .community_access_from_policy( + tenant, + request.authorization_domain(), + Scope::all_known(), + None, + ) + .map_err(|_| ProtectedResolutionError::new("community_access_invalid"))?; + let input = AuthContextInput::new( + tenant.clone(), + request.correlation_id(), + Arc::clone(request.verified_proof()), + access, + ); + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_client_status( + input, + finalization_policy, + authorization, + snapshot, + binding_bound, + ) + .map_err(|_| ProtectedResolutionError::new("status_finalization"))?; + let dependencies = AuthorizationDependencies::from_verification_only( + request.session_target(), + &disposition, + ) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let current = LeaseCurrentState::from_trusted_runtime( + disposition.binding_version(), + disposition.profile_id().clone(), + disposition.policy_version().clone(), + ); + Ok(super::transport::ProtectedStatusResolution::new( + disposition, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + fence.generation(), + )) + } + + async fn resolve( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let tenant = self.tenant(request.authorization_domain())?; + let fence = self + .invalidation + .capture_before_evaluation(request.authorization_domain()) + .await + .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; + let capabilities = CapabilitySet::single(request.capability()); + + if let Some(assertion) = request.enrollment_assertion() { + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let outcome = self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_enrollment( + tenant, + request.verified_proof(), + assertion, + finalization_policy, + snapshot, + request.correlation_id(), + ) + .map_err(|_| ProtectedResolutionError::new("enrollment_finalization"))?; + let dependencies = + AuthorizationDependencies::from_enrollment(request.session_target(), &disposition) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let current = LeaseCurrentState::from_trusted_runtime( + buzz_auth::BindingVersion::INITIAL, + disposition.profile_id().clone(), + disposition.policy_version().clone(), + ); + return Ok(ProtectedResolution::enrollment( + disposition, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + )); + } + + self.require_membership(request).await?; + let adapter = VerifiedEvidenceAdapter::new(); + let evaluation_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let (authorization, snapshot) = if let Some(owner) = request.owner_pubkey() { + let binding = self + .active_binding(request.authorization_domain(), owner, None) + .await?; + let outcome = self + .finalizer + .evaluate_delegated( + tenant, + request.verified_proof(), + &binding, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let admission = snapshot + .verified_owner_admission(&binding) + .map_err(|_| ProtectedResolutionError::new("owner_admission_mismatch"))?; + ( + FederatedAuthorization::Delegated { + owner: binding, + admission, + }, + snapshot, + ) + } else { + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let binding = self + .active_binding( + request.authorization_domain(), + request.actor_pubkey(), + Some(assertion), + ) + .await?; + let outcome = self + .finalizer + .evaluate_direct( + tenant, + request.verified_proof(), + assertion, + evaluation_policy, + capabilities, + request.correlation_id(), + ) + .await + .map_err(|_| ProtectedResolutionError::new("provider_request_invalid"))?; + let AuthorizationOutcome::Allow(snapshot) = outcome else { + return Err(ProtectedResolutionError::new("provider_denied")); + }; + let owned_assertion = self.reseal_assertion(assertion)?; + ( + FederatedAuthorization::Direct { + binding, + assertion: owned_assertion, + }, + snapshot, + ) + }; + let binding = match &authorization { + FederatedAuthorization::Direct { binding, .. } => binding, + FederatedAuthorization::Delegated { owner, .. } => owner, + FederatedAuthorization::NotRequired => { + unreachable!("protected resolver requires identity") + } + }; + let binding_bound = BindingLeaseBound::new(binding, snapshot.effective_until()) + .map_err(|_| ProtectedResolutionError::new("binding_bound_invalid"))?; + let access = adapter + .community_access_from_policy( + tenant, + request.authorization_domain(), + Scope::all_known(), + None, + ) + .map_err(|_| ProtectedResolutionError::new("community_access_invalid"))?; + let input = AuthContextInput::new( + tenant.clone(), + request.correlation_id(), + Arc::clone(request.verified_proof()), + access, + ); + let finalization_policy = self + .current_policy(request.authorization_domain(), request.correlation_id()) + .await?; + let disposition = self + .finalizer + .finalize_allowed( + input, + finalization_policy, + authorization, + snapshot, + binding_bound, + LeaseVersion::INITIAL, + ) + .map_err(|_| ProtectedResolutionError::new("authorization_finalization"))?; + let RuntimeAuthorizationDisposition::Access(context) = disposition else { + return Err(ProtectedResolutionError::new( + "non_authoritative_disposition", + )); + }; + let dependencies = + AuthorizationDependencies::from_context(request.session_target(), &context) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let observer = self + .invalidation + .observe_authority(fence, dependencies, request.cancellation()) + .map_err(|_| ProtectedResolutionError::new("invalidation_race"))?; + let lease = context + .authorization_lease() + .ok_or(ProtectedResolutionError::new("access_lease_missing"))?; + let current = LeaseCurrentState::from_trusted_runtime( + lease.binding_version(), + lease.profile_id().clone(), + lease.policy_version().clone(), + ); + Ok(ProtectedResolution::access( + context, + Arc::new(ProductionLeaseObserver { + invalidation: observer, + current, + }), + )) + } +} + +/// Build the optional exact-domain runtime from provider-neutral environment +/// configuration. An absent or blank domain list installs nothing. +pub async fn build_from_environment( + state: &crate::state::AppState, +) -> Result, ProductionRuntimeError> { + build_from_environment_with_providers(state, ProductionProviderRegistry::default()).await +} + +/// Build and install the disabled-by-default stock runtime. +/// +/// The stock OSS binary has no admission provider and therefore fails closed +/// if a non-Off domain is configured. A deployment composition root with an +/// exact O2 provider calls [`install_from_environment_with_providers`] instead. +pub async fn install_from_environment( + state: &Arc, +) -> Result { + install_from_environment_with_providers(state, ProductionProviderRegistry::default()).await +} + +/// Build and install one exact provider-neutral production runtime. +/// +/// This is the sole production composition seam for externally supplied O2 +/// providers. Restore and invalidation state is initialized before either is +/// made reachable from `AppState`; missing providers and partial installation +/// fail startup rather than falling back to legacy authorization. +pub async fn install_from_environment_with_providers( + state: &Arc, + providers: ProductionProviderRegistry, +) -> Result { + if state.protected_transport().is_some() || state.restore_protection().is_some() { + return Err(ProductionRuntimeError::AlreadyInstalled); + } + let Some(installed) = build_from_environment_with_providers(state, providers).await? else { + return Ok(ProtectedRuntimeInstallation::Disabled); + }; + let InstalledProtectedRuntime { + transport, + invalidation, + restore, + enforce_enabled, + enforcing_domains, + projection_domains, + } = installed; + let audio_restore = Arc::clone(&restore); + state + .install_restore_protection(restore) + .map_err(|_| ProductionRuntimeError::AlreadyInstalled)?; + state + .install_protected_transport(transport) + .map_err(|_| ProductionRuntimeError::AlreadyInstalled)?; + + let invalidation_worker = invalidation.clone(); + let invalidation_failure = invalidation.clone(); + let authorization_hint_subscriber = Arc::clone(&state.pubsub); + let mut workers = tokio::task::JoinSet::new(); + workers.spawn(async move { invalidation_worker.run().await }); + workers.spawn(async move { + authorization_hint_subscriber + .run_authorization_invalidation_subscriber() + .await; + }); + if enforce_enabled { + workers.spawn(run_audio_reconciliation( + state.db.clone(), + audio_restore, + enforcing_domains, + )); + } + workers.spawn( + crate::corporate_identity::run_public_projection_retirement_reconciliation( + Arc::clone(state), + projection_domains, + ), + ); + // Any required worker exit is a runtime-health failure. A detached bare + // worker could otherwise panic while Enforce kept serving from stale + // authority. The supervisor first invalidates every domain, then aborts + // the remaining workers; all protected observations fail closed. + tokio::spawn(async move { + let completion = workers.join_next().await; + invalidation_failure.fail_closed(); + workers.abort_all(); + while workers.join_next().await.is_some() {} + match completion { + Some(Ok(())) => { + tracing::error!( + "required protected authorization worker exited; runtime failed closed" + ) + } + Some(Err(error)) => tracing::error!( + %error, + "required protected authorization worker failed; runtime failed closed" + ), + None => tracing::error!( + "protected authorization worker set was empty; runtime failed closed" + ), + } + }); + Ok(ProtectedRuntimeInstallation::Installed) +} + +/// Build the disabled-by-default runtime with exact provider implementations +/// supplied by the deployment adapter. The stock OSS binary supplies an empty +/// registry, so any configured non-Off domain fails closed instead of using a +/// permissive fallback. +pub async fn build_from_environment_with_providers( + state: &crate::state::AppState, + providers: ProductionProviderRegistry, +) -> Result, ProductionRuntimeError> { + let raw = env::var(DOMAINS_ENV).unwrap_or_default(); + let configured = parse_domains(&raw)?; + let activated = state.db.activated_authorization_domains().await?; + validate_activated_domain_configuration(&configured, activated.iter().copied())?; + if configured.is_empty() { + return Ok(None); + } + validate_provider_coverage(&configured, &providers)?; + if configured.values().any(|mode| mode.evaluates_provider()) + && state.identity_assertion_provenance().is_none() + { + return Err(ProductionRuntimeError::AssertionProvenanceMissing); + } + let protected_domains = protected_domains(&configured); + let enforcing_domains = enforcing_domains(&configured); + let projection_domains = projection_reconciliation_domains(&configured); + if !enforcing_domains.is_empty() && state.corporate_identity.is_none() { + return Err(ProductionRuntimeError::VerifierMissing); + } + let clock: SharedAuthorizationClock = Arc::new(SystemAuthorizationClock); + let restore_bootstraps = parse_restore_bootstraps( + &env::var(RESTORE_BOOTSTRAPS_ENV).unwrap_or_default(), + &protected_domains, + )?; + let profile = env::var(PROFILE_ENV).unwrap_or_else(|_| "current-membership-v1".to_owned()); + let lease_seconds = parse_positive_seconds(LEASE_SECONDS_ENV, 300)?; + let lease_limit = ApplicationLeaseLimit::from_seconds(lease_seconds)?; + let status_limit = ApplicationLeaseLimit::from_seconds(lease_seconds.min(60))?; + let skew = AuthorizationClockSkew::from_seconds(0)?; + let mut policies = Vec::with_capacity(configured.len()); + let mut transports = Vec::with_capacity(configured.len()); + for (domain, mode) in &configured { + transports.push(DomainTransportPolicy::from_server_configuration( + *domain, *mode, + )); + if !mode.evaluates_provider() { + continue; + } + let provider = providers.provider_for(*domain)?; + policies.push(DomainAuthorizationPolicy::from_server_configuration( + *domain, + profile.clone(), + provider, + EnrollmentMode::AttestedKey, + *mode, + ProviderTimeout::new(Duration::from_secs(2))?, + AccessLeasePolicy::new(lease_limit, skew), + VerificationStatusPolicy::new(status_limit, skew), + )?); + } + let hosts = state.db.usage_community_hosts().await?; + let host_map = hosts + .into_iter() + .map(|entry| { + ( + CommunityId::from_uuid(entry.id), + TenantContext::resolved(CommunityId::from_uuid(entry.id), entry.host), + ) + }) + .collect::>(); + for domain in configured.keys() { + if !host_map.contains_key(domain) { + return Err(ProductionRuntimeError::ConfiguredDomainMissing); + } + } + let restore = super::restore::RestoreProtectionRuntime::initialize( + state.db.clone(), + state.git_store.clone(), + restore_bootstraps, + ) + .await?; + activate_protected_domains(&state.db, &restore, protected_domains.iter().copied()).await?; + reconcile_audio_admissions_once(&state.db, &restore, enforcing_domains.iter().copied()).await?; + let invalidation = AuthorizationInvalidationRuntime::new_with_restore( + state.db.clone(), + Arc::clone(&state.pubsub), + AuthorizationInvalidationConfig::default(), + Arc::clone(&restore), + ); + invalidation + .initialize_domains(protected_domains.iter().copied()) + .await?; + crate::corporate_identity::reconcile_public_projection_retirements_startup( + state, + &projection_domains, + ) + .await + .map_err(|_| ProductionRuntimeError::PublicProjection)?; + let finalizer = RelayAuthorizationFinalizer::new( + DomainProviderSelector::new(policies)?, + Arc::clone(&clock), + ); + let resolver: Arc = Arc::new(ProductionResolver { + db: state.db.clone(), + tenants: host_map, + finalizer, + invalidation: invalidation.clone(), + clock: Arc::clone(&clock), + }); + let transport = Arc::new(ProtectedTransportRuntime::new(transports, resolver, clock)?); + Ok(Some(InstalledProtectedRuntime { + transport, + invalidation, + restore, + enforce_enabled: !enforcing_domains.is_empty(), + enforcing_domains, + projection_domains, + })) +} + +async fn activate_protected_domains( + db: &buzz_db::Db, + restore: &Arc, + domains: impl IntoIterator, +) -> Result<(), ProductionRuntimeError> { + for domain in domains { + let mut digest = Sha256::new(); + digest.update(b"buzz-protected-domain-activation-v1"); + digest.update(domain.as_uuid().as_bytes()); + let fingerprint: [u8; 32] = digest.finalize().into(); + let operation_id = super::executor::ProtectedOperationId::derive( + domain, + "runtime.domain.activate.v1", + &fingerprint, + )? + .as_uuid(); + let witness = restore.begin(domain, operation_id, fingerprint).await?; + match db + .activate_authorization_domain(domain, operation_id, fingerprint) + .await + { + Ok(()) => witness.commit().await?, + Err(error) => { + let committed = db + .authorization_operation_receipt_fingerprint(domain, operation_id) + .await?; + if committed == Some(fingerprint) { + witness.commit().await?; + } else { + witness.abort().await?; + return Err(error.into()); + } + } + } + } + Ok(()) +} + +fn validate_provider_coverage( + configured: &HashMap, + providers: &ProductionProviderRegistry, +) -> Result<(), ProductionRuntimeError> { + for (domain, mode) in configured { + if mode.evaluates_provider() { + providers.provider_for(*domain)?; + } + } + Ok(()) +} + +fn validate_activated_domain_configuration( + configured: &HashMap, + activated: impl IntoIterator, +) -> Result<(), ProductionRuntimeError> { + if activated.into_iter().any(|domain| { + !configured + .get(&domain) + .is_some_and(|mode| mode.protects_surfaces()) + }) { + return Err(ProductionRuntimeError::ActivatedDomainDowngrade); + } + Ok(()) +} + +/// Reconcile expired durable audio attempts. Discovery is read-only; an +/// unexpired claimant remains exclusively owned by its healthy replica and +/// every orphan transition is independently witnessed. +pub async fn run_audio_reconciliation( + db: buzz_db::Db, + restore: Arc, + enforcing_domains: Vec, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + if let Err(error) = + reconcile_audio_admissions_once(&db, &restore, enforcing_domains.iter().copied()).await + { + tracing::warn!(%error, "durable audio admission reconciliation failed"); + } + } +} + +async fn reconcile_audio_admissions_once( + db: &buzz_db::Db, + restore: &Arc, + domains: impl IntoIterator, +) -> Result { + let mut reconciled = 0_u64; + let mut first_error: Option = None; + for domain in domains { + let mut domain_failed = false; + for sweep in 0..MAX_AUDIO_RECONCILIATION_SWEEPS { + let mut cursor = None; + loop { + let discovered = buzz_db::audio_admission::reconcilable_audio_admissions_after( + db, domain, cursor, + ) + .await; + let candidates = match discovered { + Ok(candidates) => candidates, + Err(error) => { + tracing::warn!(%error, "durable audio admission discovery failed for one domain"); + first_error.get_or_insert_with(|| error.into()); + domain_failed = true; + break; + } + }; + let Some(last) = candidates.last().map(|candidate| candidate.admission_id) else { + break; + }; + cursor = Some(last); + for candidate in candidates { + match reconcile_audio_admission(db, restore, domain, candidate).await { + Ok(true) => reconciled = reconciled.saturating_add(1), + Ok(false) => {} + Err(error) => { + tracing::warn!( + %error, + "durable audio admission candidate failed without starving later cleanup" + ); + first_error.get_or_insert(error); + domain_failed = true; + } + } + } + } + if domain_failed { + break; + } + let remaining = + buzz_db::audio_admission::reconcilable_audio_admissions(db, domain).await; + match remaining { + Ok(remaining) if remaining.is_empty() => break, + Ok(_) if sweep + 1 < MAX_AUDIO_RECONCILIATION_SWEEPS => continue, + Ok(_) => { + first_error + .get_or_insert(ProductionRuntimeError::AudioReconciliationIncomplete); + break; + } + Err(error) => { + first_error.get_or_insert_with(|| error.into()); + break; + } + } + } + } + match first_error { + Some(error) => Err(error), + None => Ok(reconciled), + } +} + +async fn reconcile_audio_admission( + db: &buzz_db::Db, + restore: &Arc, + domain: CommunityId, + candidate: buzz_db::audio_admission::AudioAdmissionReconciliationCandidate, +) -> Result { + use super::executor::ProtectedOperationId; + + // Reconciliation never proves a graceful disconnect. Even a formerly + // visible orphan is conservatively compensated as aborted after its + // durable ownership deadline and grace period. + let finished = false; + let terminal = b"aborted".as_slice(); + let mut stable = Sha256::new(); + stable.update(b"buzz-audio-admission-reconciliation-v1"); + stable.update(candidate.admission_id.as_bytes()); + stable.update(candidate.claimant_id.as_bytes()); + stable.update(candidate.source_state.as_str().as_bytes()); + stable.update(terminal); + stable.update(candidate.state_version.to_be_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation_id = + ProtectedOperationId::derive(domain, "audio.admission.reconcile.v1", &stable) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?; + let mut request = Sha256::new(); + request.update(b"buzz-audio-admission-reconciliation-request-v1"); + request.update(stable); + let request: [u8; 32] = request.finalize().into(); + let witness = restore + .begin(domain, operation_id.as_uuid(), request) + .await?; + match buzz_db::audio_admission::reconcile_claimed_audio_admission_with_receipt( + db, + domain, + candidate, + finished, + Some("orphaned_attachment"), + operation_id.as_uuid(), + request, + ) + .await + { + Ok(true) => witness.commit().await?, + Ok(false) => { + witness.abort().await?; + return Ok(false); + } + Err(error) => { + witness.abort().await?; + return Err(error.into()); + } + } + Ok(true) +} + +fn parse_positive_seconds(name: &'static str, default: u64) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or(ProductionRuntimeError::InvalidConfiguration), + Err(env::VarError::NotPresent) => Ok(default), + Err(env::VarError::NotUnicode(_)) => Err(ProductionRuntimeError::InvalidConfiguration), + } +} + +fn parse_domains( + raw: &str, +) -> Result, ProductionRuntimeError> { + let mut domains = HashMap::new(); + for item in raw + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + let (id, mode) = item + .split_once(':') + .ok_or(ProductionRuntimeError::InvalidConfiguration)?; + let domain = CommunityId::from_uuid( + uuid::Uuid::parse_str(id).map_err(|_| ProductionRuntimeError::InvalidConfiguration)?, + ); + let mode = match mode.trim().to_ascii_lowercase().as_str() { + "off" => AuthorizationMode::Off, + "shadow" => AuthorizationMode::Shadow, + "verify_only" => AuthorizationMode::VerifyOnly, + "enforce" => AuthorizationMode::Enforce, + "deny_protected" => AuthorizationMode::DenyProtected, + _ => return Err(ProductionRuntimeError::InvalidConfiguration), + }; + if domains.insert(domain, mode).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + Ok(domains) +} + +fn protected_domains(configured: &HashMap) -> Vec { + configured + .iter() + .filter_map(|(domain, mode)| mode.protects_surfaces().then_some(*domain)) + .collect() +} + +fn enforcing_domains(configured: &HashMap) -> Vec { + configured + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() +} + +fn projection_reconciliation_domains( + configured: &HashMap, +) -> Vec { + // Public projection retirement belongs to authoritative Enforce runtime. + // Observational modes must not queue or publish projection changes. + configured + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() +} + +fn parse_restore_bootstraps( + raw: &str, + protected_domains: &[CommunityId], +) -> Result, ProductionRuntimeError> { + let mut anchors = HashMap::new(); + for item in raw + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + let (domain, bootstrap) = item + .split_once('=') + .ok_or(ProductionRuntimeError::InvalidConfiguration)?; + let domain = CommunityId::from_uuid( + uuid::Uuid::parse_str(domain.trim()) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?, + ); + let bootstrap = uuid::Uuid::parse_str(bootstrap.trim()) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?; + if bootstrap.is_nil() || anchors.insert(domain, bootstrap).is_some() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + } + let mut result = Vec::with_capacity(protected_domains.len()); + for domain in protected_domains { + let bootstrap = anchors + .remove(domain) + .ok_or(ProductionRuntimeError::RestoreBootstrapMissing)?; + result.push((*domain, bootstrap)); + } + if !anchors.is_empty() { + return Err(ProductionRuntimeError::InvalidConfiguration); + } + Ok(result) +} + +/// Fail-closed production construction error. +#[derive(Debug, Error)] +pub enum ProductionRuntimeError { + /// Configuration was malformed or ambiguous. + #[error("protected authorization configuration is invalid")] + InvalidConfiguration, + /// An exact configured domain has no durable host mapping. + #[error("protected authorization domain is not present")] + ConfiguredDomainMissing, + /// Typed provider or finalization configuration was rejected. + #[error(transparent)] + Provider(#[from] buzz_auth::ProviderContractError), + /// A configured authoritative or observational domain has no exact O2 provider. + #[error("protected authorization provider is not configured for this domain")] + ProviderMissing, + /// Protected production state was already partially or fully installed. + #[error("protected authorization runtime is already installed")] + AlreadyInstalled, + /// Enforce was requested without a complete domain-usable assertion verifier. + #[error("protected authorization assertion verifier is not configured")] + VerifierMissing, + /// A protected domain was configured without deployment-verified ingress + /// provenance for its direct identity assertion. + #[error("protected authorization assertion provenance is not configured")] + AssertionProvenanceMissing, + /// A previously activated domain was omitted or configured non-authoritatively. + #[error("protected authorization domain cannot be downgraded after activation")] + ActivatedDomainDowngrade, + /// An Enforce domain has no exact externally provisioned restore anchor. + #[error("protected authorization restore bootstrap is not configured")] + RestoreBootstrapMissing, + /// Lease bounds were rejected. + #[error(transparent)] + Lease(#[from] buzz_auth::LeasePolicyError), + /// Durable domain construction failed. + #[error(transparent)] + Database(#[from] buzz_db::DbError), + /// Invalidation initialization failed. + #[error(transparent)] + Invalidation(#[from] super::invalidation::AuthorizationInvalidationRuntimeError), + /// Exact-domain policy construction failed. + #[error(transparent)] + DomainPolicy(#[from] super::finalization::DomainPolicyError), + /// Protected transport construction failed. + #[error(transparent)] + Transport(#[from] super::transport::ProtectedTransportError), + /// Independent restore witness initialization failed. + #[error(transparent)] + Restore(#[from] super::restore::RestoreProtectionError), + /// Stable operation construction failed before production activation. + #[error(transparent)] + Execution(#[from] super::executor::AuthorizationExecutionError), + /// Public projection startup reconciliation failed. + #[error("public identity projection reconciliation failed")] + PublicProjection, + /// Startup could not prove that all durable audio remnants were reconciled. + #[error("protected audio reconciliation did not reach a complete fixed point")] + AudioReconciliationIncomplete, +} + +#[cfg(test)] +mod tests { + use buzz_auth::{ + AuthorizationProviderFuture, AuthorizationRequest, ProviderDecision, ProviderUnavailable, + ProviderUnavailableReason, + }; + + use super::*; + + struct SyntheticUnavailableProvider; + + impl AuthorizationProvider for SyntheticUnavailableProvider { + fn profile_id(&self) -> buzz_auth::AuthorizationProfileId { + buzz_auth::AuthorizationProfileId::from_server_configuration( + "profile.synthetic-unavailable.example", + ) + .expect("synthetic profile is valid") + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async { + ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + }) + } + } + + #[test] + fn absent_configuration_is_disabled() { + assert!(parse_domains("").expect("empty is disabled").is_empty()); + } + + #[test] + fn exact_modes_parse_without_a_default() { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let third = uuid::Uuid::new_v4(); + let parsed = parse_domains(&format!( + "{first}:enforce,{second}:verify_only,{third}:deny_protected" + )) + .expect("valid exact domains"); + assert_eq!( + parsed.get(&CommunityId::from_uuid(first)), + Some(&AuthorizationMode::Enforce) + ); + assert_eq!( + parsed.get(&CommunityId::from_uuid(second)), + Some(&AuthorizationMode::VerifyOnly) + ); + assert_eq!( + parsed.get(&CommunityId::from_uuid(third)), + Some(&AuthorizationMode::DenyProtected) + ); + } + + #[test] + fn duplicate_or_unknown_domains_fail_closed() { + let id = uuid::Uuid::new_v4(); + assert!(parse_domains(&format!("{id}:enforce,{id}:shadow")).is_err()); + assert!(parse_domains(&format!("{id}:automatic")).is_err()); + } + + #[test] + fn observational_modes_have_no_durable_runtime_domains() { + let off = uuid::Uuid::new_v4(); + let shadow = uuid::Uuid::new_v4(); + let verify = uuid::Uuid::new_v4(); + let enforce = uuid::Uuid::new_v4(); + let deny = uuid::Uuid::new_v4(); + let parsed = parse_domains(&format!( + "{off}:off,{shadow}:shadow,{verify}:verify_only,{enforce}:enforce,{deny}:deny_protected" + )) + .expect("valid exact modes"); + let protected = protected_domains(&parsed) + .into_iter() + .collect::>(); + assert_eq!(protected.len(), 2); + assert!(protected.contains(&CommunityId::from_uuid(enforce))); + assert!(protected.contains(&CommunityId::from_uuid(deny))); + assert_eq!( + enforcing_domains(&parsed), + vec![CommunityId::from_uuid(enforce)] + ); + let projection_domains = projection_reconciliation_domains(&parsed) + .into_iter() + .collect::>(); + assert_eq!(projection_domains.len(), 1); + assert!(!projection_domains.contains(&CommunityId::from_uuid(off))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(shadow))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(verify))); + assert!(projection_domains.contains(&CommunityId::from_uuid(enforce))); + assert!(!projection_domains.contains(&CommunityId::from_uuid(deny))); + } + + #[test] + fn activated_domain_cannot_be_omitted_or_downgraded() { + let activated = CommunityId::from_uuid(uuid::Uuid::new_v4()); + assert!(matches!( + validate_activated_domain_configuration(&HashMap::new(), [activated]), + Err(ProductionRuntimeError::ActivatedDomainDowngrade) + )); + for mode in [ + AuthorizationMode::Off, + AuthorizationMode::Shadow, + AuthorizationMode::VerifyOnly, + ] { + let configured = HashMap::from([(activated, mode)]); + assert!(matches!( + validate_activated_domain_configuration(&configured, [activated]), + Err(ProductionRuntimeError::ActivatedDomainDowngrade) + )); + } + let configured = HashMap::from([(activated, AuthorizationMode::Enforce)]); + validate_activated_domain_configuration(&configured, [activated]) + .expect("exact Enforce configuration preserves one-way activation"); + let configured = HashMap::from([(activated, AuthorizationMode::DenyProtected)]); + validate_activated_domain_configuration(&configured, [activated]) + .expect("deny-protected preserves the protected inventory after activation"); + } + + #[test] + fn exact_provider_registry_has_no_fallback() { + let configured = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let absent = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let provider: Arc = Arc::new(SyntheticUnavailableProvider); + let registry = ProductionProviderRegistry::new([(configured, provider)]) + .expect("exact provider registry"); + assert!(registry.provider_for(configured).is_ok()); + assert!(matches!( + registry.provider_for(absent), + Err(ProductionRuntimeError::ProviderMissing) + )); + } + + #[test] + fn exact_provider_coverage_makes_enforce_constructible_without_fallback() { + let enforce = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let off = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let deny = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let configured = parse_domains(&format!( + "{enforce}:enforce,{off}:off,{deny}:deny_protected" + )) + .expect("exact production configuration"); + assert!(matches!( + validate_provider_coverage(&configured, &ProductionProviderRegistry::default()), + Err(ProductionRuntimeError::ProviderMissing) + )); + let provider: Arc = Arc::new(SyntheticUnavailableProvider); + let providers = ProductionProviderRegistry::new([(enforce, provider)]) + .expect("exact provider registry"); + validate_provider_coverage(&configured, &providers) + .expect("an exact Enforce provider reaches production construction"); + } + + #[test] + fn stock_binary_uses_the_single_production_installation_boundary() { + let main = include_str!("../main.rs"); + assert!(main.contains("production::install_from_environment(&state)")); + assert!(!main.contains("production::build_from_environment(&state)")); + assert!(!main.contains("migration::prepare_postgres_authority(&state")); + let install = main + .find("production::install_from_environment(&state)") + .expect("single production installation"); + let cutover_verify = main + .find("migration::require_reconciled_authority(&state") + .expect("read-only cutover verification"); + assert!(install < cutover_verify); + } + + #[test] + fn enforce_domain_activation_precedes_snapshot_and_transport_reachability() { + let source = include_str!("production.rs"); + let activation = source + .find("activate_protected_domains(&state.db") + .expect("durable activation is part of construction"); + let snapshot = source + .find(".initialize_domains(protected_domains.iter().copied())") + .expect("invalidation snapshot is initialized"); + let transport = source + .find("ProtectedTransportRuntime::new(transports, resolver, clock)") + .expect("transport is constructed"); + assert!(activation < snapshot); + assert!(snapshot < transport); + } + + #[test] + fn production_supervises_cross_replica_authorization_hints() { + let source = include_str!("production.rs"); + let worker_set = source + .find("let mut workers = tokio::task::JoinSet::new()") + .expect("protected worker supervisor"); + let invalidation_runtime = source + .find("invalidation_worker.run().await") + .expect("durable invalidation runtime worker"); + let redis_subscriber = source + .find("run_authorization_invalidation_subscriber()") + .expect("cross-replica authorization hint subscriber"); + let supervisor = source + .find("let completion = workers.join_next().await") + .expect("fail-closed worker supervisor"); + + assert!(worker_set < invalidation_runtime); + assert!(worker_set < redis_subscriber); + assert!(invalidation_runtime < supervisor); + assert!(redis_subscriber < supervisor); + } + + #[test] + fn production_reconciles_projection_before_reachability_and_supervises_retry_worker() { + let source = include_str!("production.rs"); + let projection_domains = source + .find("let projection_domains = projection_reconciliation_domains(&configured)") + .expect("exact projection domain selection"); + let startup = source + .find("reconcile_public_projection_retirements_startup(") + .expect("startup reconciliation"); + let transport = source + .find("ProtectedTransportRuntime::new(transports, resolver, clock)") + .expect("transport construction"); + let worker_set = source + .find("let mut workers = tokio::task::JoinSet::new()") + .expect("protected worker supervisor"); + let retry_worker = source + .find("run_public_projection_retirement_reconciliation(") + .expect("continuous projection retry worker"); + let supervisor = source + .find("let completion = workers.join_next().await") + .expect("fail-closed worker supervisor"); + + assert!(projection_domains < startup); + assert!(startup < transport); + assert!(worker_set < retry_worker); + assert!(retry_worker < supervisor); + } + + #[test] + fn restore_bootstraps_are_exact_and_non_nil() { + let domain = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let bootstrap = uuid::Uuid::new_v4(); + assert_eq!( + parse_restore_bootstraps(&format!("{domain}={bootstrap}"), &[domain]) + .expect("exact bootstrap"), + vec![(domain, bootstrap)] + ); + assert!(matches!( + parse_restore_bootstraps("", &[domain]), + Err(ProductionRuntimeError::RestoreBootstrapMissing) + )); + assert!( + parse_restore_bootstraps(&format!("{domain}={}", uuid::Uuid::nil()), &[domain]) + .is_err() + ); + } +} 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..60fa8e2637 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/restore.rs @@ -0,0 +1,872 @@ +//! Object-store witnessed high-water protection against stale PostgreSQL restore. +//! +//! The existing object store is the independent durability domain. A protected +//! mutation writes pending before its PostgreSQL commit and advances the +//! committed vector afterward. Startup refuses any database below a witnessed +//! floor and refuses an ambiguous pending checkpoint. + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use buzz_core::CommunityId; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::sync::{Mutex, OwnedMutexGuard}; +use uuid::Uuid; + +use crate::api::git::store::{CasOutcome, ETag, GitStore, Precond}; + +const FORMAT_VERSION: u32 = 2; +const BEGIN_RETRY_LIMIT: usize = 16; +const BEGIN_RETRY_DELAY: Duration = Duration::from_millis(10); + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct VersionVector { + bindings: BTreeMap, + git_publications: BTreeMap, + media_publications: BTreeMap, + object_authority: BTreeMap, + invalidation_generation: u64, + authority_epoch: u64, + status_revision: u64, +} + +impl From for VersionVector { + fn from(value: buzz_db::authorization_version::AuthorizationVersionVector) -> Self { + Self { + bindings: value.bindings, + git_publications: value.git_publications, + media_publications: value.media_publications, + object_authority: value.object_authority, + invalidation_generation: value.invalidation_generation, + authority_epoch: value.authority_epoch, + status_revision: value.status_revision, + } + } +} + +impl VersionVector { + fn to_db(&self) -> buzz_db::authorization_version::AuthorizationVersionVector { + buzz_db::authorization_version::AuthorizationVersionVector { + bindings: self.bindings.clone(), + git_publications: self.git_publications.clone(), + media_publications: self.media_publications.clone(), + object_authority: self.object_authority.clone(), + invalidation_generation: self.invalidation_generation, + authority_epoch: self.authority_epoch, + status_revision: self.status_revision, + } + } + + fn dominates(&self, floor: &Self) -> bool { + self.to_db().dominates(&floor.to_db()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum CheckpointState { + Committed, + Pending { + operation_id: Uuid, + request_fingerprint: [u8; 32], + previous: VersionVector, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct Checkpoint { + format_version: u32, + community_id: Uuid, + bootstrap_id: Uuid, + vector: VersionVector, + #[serde(flatten)] + state: CheckpointState, +} + +/// Disabled-by-default restore witness runtime. +pub struct RestoreProtectionRuntime { + db: buzz_db::Db, + store: GitStore, + domains: BTreeMap>>, + bootstrap_ids: BTreeMap, +} + +impl RestoreProtectionRuntime { + /// Initialize exact configured domains and verify every external floor. + pub async fn initialize( + db: buzz_db::Db, + store: GitStore, + domains: impl IntoIterator, + ) -> Result, RestoreProtectionError> { + let configured = domains.into_iter().collect::>(); + if configured + .iter() + .any(|(domain, bootstrap)| domain.as_uuid().is_nil() || bootstrap.is_nil()) + { + return Err(RestoreProtectionError::InvalidBootstrap); + } + let runtime = Arc::new(Self { + db, + store, + domains: configured + .keys() + .copied() + .map(|domain| (domain, Arc::new(Mutex::new(())))) + .collect(), + bootstrap_ids: configured, + }); + for domain in runtime.domains.keys().copied().collect::>() { + runtime.verify_or_initialize(domain).await?; + } + Ok(runtime) + } + + /// Explicitly provision the independent witness before enabling Enforce. + /// Normal runtime startup never calls this method and never initializes a + /// missing checkpoint from potentially restored PostgreSQL state. + pub async fn provision_domain( + db: &buzz_db::Db, + store: &GitStore, + domain: CommunityId, + bootstrap_id: Uuid, + ) -> Result<(), RestoreProtectionError> { + if bootstrap_id.is_nil() { + return Err(RestoreProtectionError::InvalidBootstrap); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id, + vector: db.authorization_version_vector(domain).await?.into(), + state: CheckpointState::Committed, + }; + let body = serde_json::to_vec(&checkpoint) + .map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + match store + .put_pointer(&checkpoint_key(domain), &body, Precond::IfNoneMatchStar) + .await? + { + CasOutcome::Won(_) => Ok(()), + CasOutcome::LostRace => Err(RestoreProtectionError::AlreadyProvisioned), + } + } + + fn mutex(&self, domain: CommunityId) -> Result>, RestoreProtectionError> { + self.domains + .get(&domain) + .cloned() + .ok_or(RestoreProtectionError::DomainNotConfigured) + } + + fn bootstrap_id(&self, domain: CommunityId) -> Result { + self.bootstrap_ids + .get(&domain) + .copied() + .ok_or(RestoreProtectionError::DomainNotConfigured) + } + + async fn current(&self, domain: CommunityId) -> Result { + Ok(self.db.authorization_version_vector(domain).await?.into()) + } + + async fn read( + &self, + domain: CommunityId, + ) -> Result, RestoreProtectionError> { + let Some((etag, bytes)) = self.store.get_pointer(&checkpoint_key(domain)).await? else { + return Ok(None); + }; + let checkpoint: Checkpoint = serde_json::from_slice(&bytes) + .map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.format_version != FORMAT_VERSION + || checkpoint.community_id != *domain.as_uuid() + || matches!( + &checkpoint.state, + CheckpointState::Pending { + operation_id, + previous, + .. + } if operation_id.is_nil() || checkpoint.vector != *previous + ) + { + return Err(RestoreProtectionError::InvalidCheckpoint); + } + Ok(Some((etag, checkpoint))) + } + + async fn verify_or_initialize( + &self, + domain: CommunityId, + ) -> Result<(), RestoreProtectionError> { + let _guard = self.mutex(domain)?.lock_owned().await; + for attempt in 0..=BEGIN_RETRY_LIMIT { + match self.read(domain).await? { + None => return Err(RestoreProtectionError::MissingCheckpoint), + Some((etag, checkpoint)) => { + if checkpoint.bootstrap_id != self.bootstrap_id(domain)? { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if let CheckpointState::Pending { + operation_id, + request_fingerprint, + previous, + } = &checkpoint.state + { + let (fingerprint, current) = self + .db + .authorization_receipt_and_version_vector(domain, *operation_id) + .await?; + let current = VersionVector::from(current); + let recovered_vector = match fingerprint { + Some(fingerprint) if fingerprint == *request_fingerprint => { + validate_recovered_vector(previous, ¤t)?; + current + } + // The receipt belongs to another request that reused + // this operation ID. If authority never moved, this + // pending request provably did not commit and an older + // poisoned checkpoint can be cleared safely. + Some(_) if current == *previous => previous.clone(), + _ => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + }; + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: recovered_vector, + state: CheckpointState::Committed, + }; + match put_exact(&self.store, domain, &recovered, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + } + } + let current = self.current(domain).await?; + if !current.dominates(&checkpoint.vector) { + return Err(RestoreProtectionError::StaleRestore); + } + if current == checkpoint.vector { + return Ok(()); + } + return Err(RestoreProtectionError::UnwitnessedAuthorityAdvance); + } + } + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } + + /// Witness intent before a PostgreSQL mutation that may advance protected + /// binding or publication versions. + pub async fn begin( + self: &Arc, + domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + ) -> Result { + if operation_id.is_nil() { + return Err(RestoreProtectionError::InvalidOperation); + } + let lock = self.mutex(domain)?.lock_owned().await; + for attempt in 0..=BEGIN_RETRY_LIMIT { + let (etag, checkpoint) = self + .read(domain) + .await? + .ok_or(RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.bootstrap_id != self.bootstrap_id(domain)? { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if let CheckpointState::Pending { + operation_id: pending_operation, + request_fingerprint: pending_fingerprint, + previous, + } = &checkpoint.state + { + let (fingerprint, committed) = self + .db + .authorization_receipt_and_version_vector(domain, *pending_operation) + .await?; + let committed = VersionVector::from(committed); + match fingerprint { + Some(fingerprint) if fingerprint == *pending_fingerprint => { + validate_recovered_vector(previous, &committed)?; + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: committed, + state: CheckpointState::Committed, + }; + match put_exact_returning(&self.store, domain, &recovered, etag).await { + Ok(_) | Err(RestoreProtectionError::ConcurrentCheckpoint) => continue, + Err(error) => return Err(error), + } + } + Some(_) if committed == *previous => { + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: previous.clone(), + state: CheckpointState::Committed, + }; + match put_exact_returning(&self.store, domain, &recovered, etag).await { + Ok(_) | Err(RestoreProtectionError::ConcurrentCheckpoint) => continue, + Err(error) => return Err(error), + } + } + Some(_) => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + None if attempt < BEGIN_RETRY_LIMIT => { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + None => return Err(RestoreProtectionError::AmbiguousInterruptedCommit), + } + } + let (receipt, current) = self + .db + .authorization_receipt_and_version_vector(domain, operation_id) + .await?; + let current = VersionVector::from(current); + if self + .read(domain) + .await? + .is_none_or(|(stable_etag, _)| stable_etag != etag) + { + if attempt < BEGIN_RETRY_LIMIT { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + return Err(RestoreProtectionError::ConcurrentCheckpoint); + } + if !current.dominates(&checkpoint.vector) { + return Err(RestoreProtectionError::StaleRestore); + } + if current != checkpoint.vector { + return Err(RestoreProtectionError::UnwitnessedAuthorityAdvance); + } + match receipt { + Some(fingerprint) if fingerprint != request_fingerprint => { + return Err(RestoreProtectionError::OperationIdentityConflict) + } + Some(_) => { + return Ok(RestoreMutationGuard { + runtime: Arc::clone(self), + domain, + operation_id, + request_fingerprint, + pending_etag: None, + previous: checkpoint.vector, + bootstrap_id: checkpoint.bootstrap_id, + _lock: lock, + }) + } + None => {} + } + let pending = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: checkpoint.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Pending { + operation_id, + request_fingerprint, + previous: current, + }, + }; + match put_exact_returning(&self.store, domain, &pending, etag).await { + Ok(etag) => { + return Ok(RestoreMutationGuard { + runtime: Arc::clone(self), + domain, + operation_id, + request_fingerprint, + pending_etag: Some(etag), + previous: checkpoint.vector, + bootstrap_id: checkpoint.bootstrap_id, + _lock: lock, + }) + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + } + Err(error) => return Err(error), + } + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } +} + +fn validate_recovered_vector( + previous: &VersionVector, + committed: &VersionVector, +) -> Result<(), RestoreProtectionError> { + if committed.dominates(previous) { + Ok(()) + } else { + Err(RestoreProtectionError::StaleRestore) + } +} + +/// Serialized pending witness held until the PostgreSQL commit is durable. +pub struct RestoreMutationGuard { + runtime: Arc, + domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + pending_etag: Option, + previous: VersionVector, + bootstrap_id: Uuid, + _lock: OwnedMutexGuard<()>, +} + +impl RestoreMutationGuard { + /// Advance the independent committed floor after PostgreSQL commit. + pub async fn commit(self) -> Result<(), RestoreProtectionError> { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + if receipt != Some(self.request_fingerprint) { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let current = VersionVector::from(current); + if !current.dominates(&self.previous) { + return Err(RestoreProtectionError::StaleRestore); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Committed, + }; + let Some(etag) = self.pending_etag.clone() else { + return Ok(()); + }; + match put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await { + Ok(()) => Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) => { + self.converge_committed(¤t).await + } + Err(error) => Err(error), + } + } + + /// Advance an invalidation witness, converging after a stale CAS when a + /// competing replica already witnessed the exact durable generation. + pub async fn commit_invalidation(self, generation: u64) -> Result<(), RestoreProtectionError> { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + if receipt != Some(self.request_fingerprint) { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let current = VersionVector::from(current); + if !current.dominates(&self.previous) || current.invalidation_generation < generation { + return Err(RestoreProtectionError::StaleRestore); + } + if let Some(etag) = self.pending_etag.clone() { + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current.clone(), + state: CheckpointState::Committed, + }; + match put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) => {} + Err(error) => return Err(error), + } + } + self.converge_committed(¤t).await + } + + /// Converge a stale object-store CAS only when PostgreSQL still proves this + /// exact operation and the competing checkpoint covers the vector observed + /// after its durable commit. A different or regressed state never becomes + /// committed as a side effect of reconciliation. + async fn converge_committed( + &self, + committed_floor: &VersionVector, + ) -> Result<(), RestoreProtectionError> { + for attempt in 0..=BEGIN_RETRY_LIMIT { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, self.operation_id) + .await?; + let current = VersionVector::from(current); + if receipt != Some(self.request_fingerprint) + || !current.dominates(&self.previous) + || !current.dominates(committed_floor) + { + return Err(RestoreProtectionError::StaleRestore); + } + let (etag, checkpoint) = self + .runtime + .read(self.domain) + .await? + .ok_or(RestoreProtectionError::InvalidCheckpoint)?; + if checkpoint.bootstrap_id != self.bootstrap_id { + return Err(RestoreProtectionError::InvalidBootstrap); + } + if checkpoint_covers(&checkpoint, committed_floor) { + let Some(effective) = effective_checkpoint_vector(&checkpoint) else { + return Err(RestoreProtectionError::InvalidCheckpoint); + }; + if !current.dominates(effective) { + return Err(RestoreProtectionError::StaleRestore); + } + return Ok(()); + } + if let CheckpointState::Pending { + operation_id, + request_fingerprint, + previous, + } = &checkpoint.state + { + let (receipt, current) = self + .runtime + .db + .authorization_receipt_and_version_vector(self.domain, *operation_id) + .await?; + let current = VersionVector::from(current); + if receipt == Some(*request_fingerprint) && current.dominates(previous) { + let recovered = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: current, + state: CheckpointState::Committed, + }; + if !checkpoint_covers(&recovered, committed_floor) { + return Err(RestoreProtectionError::StaleRestore); + } + match put_exact(&self.runtime.store, self.domain, &recovered, etag).await { + Ok(()) => return Ok(()), + Err(RestoreProtectionError::ConcurrentCheckpoint) + if attempt < BEGIN_RETRY_LIMIT => + { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + } + } + if receipt.is_none() && attempt < BEGIN_RETRY_LIMIT { + tokio::time::sleep(BEGIN_RETRY_DELAY).await; + continue; + } + } + return Err(RestoreProtectionError::ConcurrentCheckpoint); + } + Err(RestoreProtectionError::ConcurrentCheckpoint) + } + + /// Clear a pending witness only after PostgreSQL proved the transaction + /// rolled back. The previous committed vector is restored under the exact + /// pending ETag; a concurrent writer remains fail-closed. + pub async fn abort(self) -> Result<(), RestoreProtectionError> { + if self + .runtime + .db + .authorization_operation_receipt_fingerprint(self.domain, self.operation_id) + .await? + .is_some_and(|fingerprint| fingerprint == self.request_fingerprint) + { + return Err(RestoreProtectionError::CommitAlreadyDurable); + } + let current = self.runtime.current(self.domain).await?; + if current != self.previous { + return Err(RestoreProtectionError::AmbiguousInterruptedCommit); + } + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *self.domain.as_uuid(), + bootstrap_id: self.bootstrap_id, + vector: self.previous, + state: CheckpointState::Committed, + }; + let Some(etag) = self.pending_etag.clone() else { + return Err(RestoreProtectionError::CommitAlreadyDurable); + }; + put_exact(&self.runtime.store, self.domain, &checkpoint, etag).await + } +} + +fn checkpoint_covers(checkpoint: &Checkpoint, committed_floor: &VersionVector) -> bool { + effective_checkpoint_vector(checkpoint) + .is_some_and(|effective| effective.dominates(committed_floor)) +} + +fn effective_checkpoint_vector(checkpoint: &Checkpoint) -> Option<&VersionVector> { + Some(match &checkpoint.state { + CheckpointState::Committed => &checkpoint.vector, + CheckpointState::Pending { + previous: pending_previous, + .. + } if checkpoint.vector == *pending_previous => pending_previous, + CheckpointState::Pending { .. } => return None, + }) +} + +async fn put_exact( + store: &GitStore, + domain: CommunityId, + checkpoint: &Checkpoint, + etag: ETag, +) -> Result<(), RestoreProtectionError> { + put_exact_returning(store, domain, checkpoint, etag) + .await + .map(|_| ()) +} + +async fn put_exact_returning( + store: &GitStore, + domain: CommunityId, + checkpoint: &Checkpoint, + etag: ETag, +) -> Result { + let body = + serde_json::to_vec(checkpoint).map_err(|_| RestoreProtectionError::InvalidCheckpoint)?; + match store + .put_pointer(&checkpoint_key(domain), &body, Precond::IfMatch(etag)) + .await? + { + CasOutcome::Won(etag) => Ok(etag), + CasOutcome::LostRace => Err(RestoreProtectionError::ConcurrentCheckpoint), + } +} + +fn checkpoint_key(domain: CommunityId) -> String { + format!("_authority/{domain}/authorization-version-v1.json") +} + +/// Fail-closed restore protection error. +#[derive(Debug, Error)] +pub enum RestoreProtectionError { + /// The exact domain was not configured for protected authorization. + #[error("restore protection domain is not configured")] + DomainNotConfigured, + /// The immutable bootstrap identity was missing, nil, or mismatched. + #[error("restore protection bootstrap identity is invalid")] + InvalidBootstrap, + /// A protected domain has not been explicitly provisioned. + #[error("restore protection checkpoint is missing")] + MissingCheckpoint, + /// Provisioning raced an existing checkpoint. + #[error("restore protection checkpoint is already provisioned")] + AlreadyProvisioned, + /// A checkpoint was malformed or belonged to another domain. + #[error("restore protection checkpoint is invalid")] + InvalidCheckpoint, + /// The writer database is below an independently witnessed floor. + #[error("stale PostgreSQL restore detected")] + StaleRestore, + /// PostgreSQL authority advanced without a matching pending witness. + #[error("protected authority advanced without an independent witness")] + UnwitnessedAuthorityAdvance, + /// A crash left commit outcome ambiguous; serving is unsafe. + #[error("interrupted protected commit requires reconciliation")] + AmbiguousInterruptedCommit, + /// A caller attempted to clear a pending witness after its exact database + /// operation had already become durable. + #[error("protected commit is already durable; pending witness retained")] + CommitAlreadyDurable, + /// A stable operation ID was reused with different request bytes. + #[error("protected operation identity conflicts with an existing receipt")] + OperationIdentityConflict, + /// Another writer changed the checkpoint unexpectedly. + #[error("restore protection checkpoint changed concurrently")] + ConcurrentCheckpoint, + /// Operation identity was invalid. + #[error("restore protection operation identity is invalid")] + InvalidOperation, + /// Writer database access failed. + #[error(transparent)] + Database(#[from] buzz_db::DbError), + /// Independent object-store access failed. + #[error(transparent)] + Store(#[from] crate::api::git::store::StoreError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vector_rejects_missing_and_backward_versions() { + let mut floor = VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 2, + authority_epoch: 3, + status_revision: 4, + }; + floor.bindings.insert("principal".into(), 3); + let mut current = floor.clone(); + assert!(current.dominates(&floor)); + current.bindings.insert("principal".into(), 2); + assert!(!current.dominates(&floor)); + current.bindings.clear(); + assert!(!current.dominates(&floor)); + current = floor.clone(); + current.invalidation_generation = 1; + assert!(!current.dominates(&floor)); + } + + #[test] + fn every_publication_component_is_monotonic() { + let mut floor = VersionVector { + bindings: BTreeMap::from([("binding-fingerprint".into(), 3)]), + git_publications: BTreeMap::from([("git-fingerprint".into(), 4)]), + media_publications: BTreeMap::from([("media-fingerprint".into(), 5)]), + object_authority: BTreeMap::from([("git".into(), 6), ("media".into(), 7)]), + invalidation_generation: 8, + authority_epoch: 9, + status_revision: 10, + }; + let original = floor.clone(); + for component in [ + "git", + "media", + "object", + "invalidation", + "authority", + "status", + ] { + let mut current = original.clone(); + match component { + "git" => current.git_publications.clear(), + "media" => current.media_publications.clear(), + "object" => { + current.object_authority.insert("git".into(), 5); + } + "invalidation" => current.invalidation_generation = 7, + "authority" => current.authority_epoch = 8, + "status" => current.status_revision = 9, + _ => unreachable!(), + } + assert!(!current.dominates(&original), "{component} regressed"); + } + floor.bindings.insert("new-binding".into(), 1); + assert!(floor.dominates(&original)); + } + + #[test] + fn checkpoint_wire_contains_only_opaque_version_selectors() { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let checkpoint = Checkpoint { + format_version: FORMAT_VERSION, + community_id: *domain.as_uuid(), + bootstrap_id: Uuid::new_v4(), + vector: VersionVector { + bindings: BTreeMap::from([("opaque-binding-fingerprint".into(), 2)]), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 3, + authority_epoch: 4, + status_revision: 5, + }, + state: CheckpointState::Committed, + }; + let wire = serde_json::to_string(&checkpoint).expect("checkpoint JSON"); + assert!(wire.contains("opaque-binding-fingerprint")); + for prohibited in ["issuer", "subject", "display_name", "email", "pubkey"] { + assert!(!wire.contains(prohibited)); + } + } + + #[test] + fn pending_recovery_requires_the_post_receipt_vector() { + let previous = VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 4, + authority_epoch: 5, + status_revision: 6, + }; + let mut stale_pre_receipt = previous.clone(); + stale_pre_receipt.authority_epoch = 4; + let mut committed_post_receipt = previous.clone(); + committed_post_receipt.authority_epoch = 6; + + assert!(matches!( + validate_recovered_vector(&previous, &stale_pre_receipt), + Err(RestoreProtectionError::StaleRestore) + )); + assert!(validate_recovered_vector(&previous, &committed_post_receipt).is_ok()); + } + + #[test] + fn invalidation_convergence_accepts_only_monotonic_committed_or_later_pending_floors() { + let domain = Uuid::new_v4(); + let bootstrap_id = Uuid::new_v4(); + let previous = VersionVector { + invalidation_generation: 4, + authority_epoch: 7, + status_revision: 3, + ..VersionVector { + bindings: BTreeMap::new(), + git_publications: BTreeMap::new(), + media_publications: BTreeMap::new(), + object_authority: BTreeMap::new(), + invalidation_generation: 0, + authority_epoch: 0, + status_revision: 0, + } + }; + let mut covered = previous.clone(); + covered.invalidation_generation = 5; + let committed = Checkpoint { + format_version: FORMAT_VERSION, + community_id: domain, + bootstrap_id, + vector: covered.clone(), + state: CheckpointState::Committed, + }; + assert!(checkpoint_covers(&committed, &covered)); + + let mut later_floor = covered.clone(); + later_floor.invalidation_generation = 6; + assert!(!checkpoint_covers(&committed, &later_floor)); + + let later_pending = Checkpoint { + format_version: FORMAT_VERSION, + community_id: domain, + bootstrap_id, + vector: covered.clone(), + state: CheckpointState::Pending { + operation_id: Uuid::new_v4(), + request_fingerprint: [8; 32], + previous: covered.clone(), + }, + }; + assert!(checkpoint_covers(&later_pending, &covered)); + + let mut malformed = later_pending; + malformed.vector.invalidation_generation = 6; + assert!(!checkpoint_covers(&malformed, &covered)); + + let mut regressed = committed; + regressed.vector.authority_epoch = previous.authority_epoch - 1; + assert!(!checkpoint_covers(®ressed, &covered)); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs new file mode 100644 index 0000000000..a3e702aeb3 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -0,0 +1,1717 @@ +//! Provider-neutral relay client binding status. +//! +//! This module is a one-way presentation adapter. It consumes a display-only +//! [`VerificationOnlyDisposition`](buzz_auth::VerificationOnlyDisposition) or +//! an opaque withdrawal request and returns one relay-signed +//! ephemeral event. The event has no route, ordinary ingest, event storage, +//! pub/sub, membership, capability, access-context, or lease integration. A +//! dedicated delivery trait exists behind a typed presentation +//! permit that only complete external RFC/client gate evidence can construct; +//! the stock binary supplies none and therefore remains disabled by default. + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + AuthorizationProfileId, BindingVersion, PolicyVersion, VerificationOnlyDisposition, +}; +use buzz_core::{ + client_binding_status::{ + ClientBindingStatusBuildError, ClientBindingStatusError, ClientBindingStatusInputV1, + MAX_CLIENT_BINDING_STATUS_LABEL_BYTES, + }, + CommunityId, +}; +use hmac::{Hmac, KeyInit, Mac}; +use nostr::{Event, Keys, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const POLICY_REVISION_DOMAIN_SEPARATOR: &[u8] = b"buzz-client-status-policy-v1"; + +/// Dedicated secret for unlinkable provider-neutral client-status revisions. +/// +/// Deployments must inject a purpose-specific random value. Reusing provider +/// assertion keys, relay signing keys, or any public identifier would make the +/// status revision linkable across trust domains. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientStatusPrivacyKey([u8; 32]); + +impl ClientStatusPrivacyKey { + /// Construct a client-status-only privacy key from 32 secret bytes. + pub const fn from_secret(secret: [u8; 32]) -> Self { + Self(secret) + } +} + +impl fmt::Debug for ClientStatusPrivacyKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ClientStatusPrivacyKey") + .field(&"[redacted]") + .finish() + } +} + +/// Exact scope used to obtain a durable client-status revision. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClientStatusRevisionScope { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, +} + +impl ClientStatusRevisionScope { + /// Server-resolved authorization domain. + pub const fn authorization_domain(self) -> CommunityId { + self.authorization_domain + } + + /// Exact event-author key. + pub const fn event_author_pubkey(self) -> PublicKey { + self.event_author_pubkey + } +} + +impl fmt::Debug for ClientStatusRevisionScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusRevisionScope") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .finish() + } +} + +/// Opaque proof of the exact current status delivered to one connection. +/// +/// Callers cannot manufacture a different scope or revision. A withdrawal +/// must present this receipt so it always supersedes the status users saw. +pub struct ClientStatusIssuanceReceipt { + scope: ClientStatusRevisionScope, + connection_id: Uuid, + revision: u64, + issuance_fingerprint: [u8; 32], +} + +impl ClientStatusIssuanceReceipt { + /// Exact authenticated connection that received the current status. + pub const fn connection_id(&self) -> Uuid { + self.connection_id + } + + /// Revision that a withdrawal must supersede. + pub const fn revision(&self) -> u64 { + self.revision + } +} + +impl fmt::Debug for ClientStatusIssuanceReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusIssuanceReceipt") + .field("scope", &self.scope) + .field("connection_id", &"[redacted]") + .field("revision", &"[redacted]") + .finish() + } +} + +/// Revision and durable floor read atomically from an injected persistence seam. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DurableClientStatusRevision { + revision: u64, + floor: u64, +} + +impl DurableClientStatusRevision { + /// Validate a revision/floor pair returned by durable state. + pub const fn from_durable_state( + revision: u64, + floor: u64, + ) -> Result { + if revision == 0 { + return Err(ClientStatusRevisionError::ZeroRevision); + } + if revision < floor { + return Err(ClientStatusRevisionError::BelowDurableFloor); + } + Ok(Self { revision, floor }) + } + + /// Current monotonic status revision. + pub const fn revision(self) -> u64 { + self.revision + } + + /// Lowest revision allowed by durable reconciliation state. + pub const fn floor(self) -> u64 { + self.floor + } +} + +impl fmt::Debug for DurableClientStatusRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DurableClientStatusRevision") + .field("revision", &"[redacted]") + .field("floor", &"[redacted]") + .finish() + } +} + +/// Read-only durable revision source supplied by the invalidation/reconciliation lane. +/// +/// Implementations must never synthesize a process-local fallback. `None` +/// withholds status, and restore/restart must not return a revision below its +/// persisted floor. +#[async_trait] +pub trait DurableClientStatusRevisionSource: Send + Sync { + /// Atomically revalidate an exact active/fresh binding and return its + /// current revision. `None` withholds output. + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + issuance_fingerprint: [u8; 32], + ) -> Option; + + /// Allocate a durable revision for the exact delivered current issuance. + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + withdrawal_fingerprint: [u8; 32], + ) -> Option; +} + +mod postgres; +pub use postgres::PostgresClientStatusRevisionSource; + +/// Exact private state that must still be active at the signing boundary. +pub struct ClientStatusCurrentRequirement<'a> { + scope: ClientStatusRevisionScope, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: &'a AuthorizationProfileId, + policy_version: &'a PolicyVersion, + evaluation_generation: u64, + fresh_until: u64, +} + +impl ClientStatusCurrentRequirement<'_> { + /// Exact public status scope. + pub const fn scope(&self) -> ClientStatusRevisionScope { + self.scope + } + + /// Stable binding identifier required to remain active. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact binding version required to remain active. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Exact provider profile required to remain current. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + self.profile_id + } + + /// Exact provider policy version required to remain current. + pub const fn policy_version(&self) -> &PolicyVersion { + self.policy_version + } + + /// Invalidation generation captured before provider evaluation. + pub const fn evaluation_generation(&self) -> u64 { + self.evaluation_generation + } + + /// Absolute status freshness boundary. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } +} + +impl fmt::Debug for ClientStatusCurrentRequirement<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusCurrentRequirement") + .field("scope", &self.scope) + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("evaluation_generation", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Pseudonymous policy revision safe for the client-status wire contract. +/// +/// The provider profile and opaque policy string are authenticated under a +/// dedicated privacy key with length framing. The raw profile, issuer, +/// audience, claim names, and policy value never enter the event, and equal +/// provider values are unlinkable across deployments with distinct keys. +#[derive(Clone, PartialEq, Eq)] +pub struct ProviderNeutralPolicyRevision(String); + +impl ProviderNeutralPolicyRevision { + /// Derive a provider-neutral revision from current server/provider evidence. + pub fn derive( + privacy_key: &ClientStatusPrivacyKey, + profile: &AuthorizationProfileId, + policy: &PolicyVersion, + ) -> Result { + let mut mac = as KeyInit>::new_from_slice(&privacy_key.0) + .map_err(|_| ClientStatusPrivacyError::InvalidKeyMaterial)?; + mac.update(POLICY_REVISION_DOMAIN_SEPARATOR); + update_length_framed(&mut mac, profile.as_str().as_bytes()); + update_length_framed(&mut mac, policy.as_str().as_bytes()); + Ok(Self(hex::encode(mac.finalize().into_bytes()))) + } + + /// Lowercase hex digest carried in the signed status. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ProviderNeutralPolicyRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderNeutralPolicyRevision") + .field(&"[redacted]") + .finish() + } +} + +fn update_length_framed(mac: &mut Hmac, value: &[u8]) { + mac.update(&(value.len() as u64).to_be_bytes()); + mac.update(value); +} + +/// Optional display label loaded only from privacy-approved server configuration. +/// +/// There is intentionally no constructor from assertion claims, provider +/// decisions, binding `display_name`, or mutable Nostr profiles. +#[derive(Clone, PartialEq, Eq)] +pub struct PrivacyApprovedClientStatusLabel(String); + +impl PrivacyApprovedClientStatusLabel { + /// Load a non-empty, bounded label from approved server configuration. + pub fn from_server_configuration( + value: impl Into, + ) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(PrivacyApprovedClientStatusLabelError::InvalidLabel); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PrivacyApprovedClientStatusLabel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PrivacyApprovedClientStatusLabel") + .field(&"[redacted]") + .finish() + } +} + +/// Authoritative, presentation-only evidence used to sign one status. +/// +/// Construction from verification-only finalization is preferred for current +/// display. Invalidation/reconciliation code may reuse only its exact scope +/// and freshness window to issue an opaque withdrawal. Private binding and +/// policy evidence remains server-side and is never serialized into the event. +pub struct AuthoritativeClientStatusEvidence { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + policy_revision: ProviderNeutralPolicyRevision, + issuance_id: Uuid, + evaluation_generation: u64, + issued_at: u64, + fresh_until: u64, +} + +impl AuthoritativeClientStatusEvidence { + /// Derive current display evidence from full verification-only finalization. + pub fn from_verification_only( + disposition: &VerificationOnlyDisposition, + privacy_key: &ClientStatusPrivacyKey, + evaluation_generation: u64, + ) -> Result { + Ok(Self { + authorization_domain: disposition.authorization_domain(), + event_author_pubkey: disposition.actor_pubkey(), + binding_id: disposition.binding_id(), + binding_version: disposition.binding_version(), + profile_id: disposition.profile_id().clone(), + policy_version: disposition.policy_version().clone(), + policy_revision: ProviderNeutralPolicyRevision::derive( + privacy_key, + disposition.profile_id(), + disposition.policy_version(), + )?, + issuance_id: disposition.correlation_id(), + evaluation_generation, + issued_at: disposition.issued_at(), + fresh_until: disposition.expires_at(), + }) + } + + /// Consume separately authoritative runtime/lifecycle evidence. + /// + /// Callers must use server-resolved domain/key state and centrally injected + /// time. This constructor validates representation through the core + /// contract during issuance; it performs no persistence or lifecycle read. + #[allow(clippy::too_many_arguments)] + pub const fn from_authoritative_runtime( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + policy_revision: ProviderNeutralPolicyRevision, + issuance_id: Uuid, + evaluation_generation: u64, + issued_at: u64, + fresh_until: u64, + ) -> Self { + Self { + authorization_domain, + event_author_pubkey, + binding_id, + binding_version, + profile_id, + policy_version, + policy_revision, + issuance_id, + evaluation_generation, + issued_at, + fresh_until, + } + } + + fn revision_scope(&self) -> ClientStatusRevisionScope { + ClientStatusRevisionScope { + authorization_domain: self.authorization_domain, + event_author_pubkey: self.event_author_pubkey, + } + } + + fn current_requirement(&self) -> ClientStatusCurrentRequirement<'_> { + ClientStatusCurrentRequirement { + scope: self.revision_scope(), + binding_id: self.binding_id, + binding_version: self.binding_version, + profile_id: &self.profile_id, + policy_version: &self.policy_version, + evaluation_generation: self.evaluation_generation, + fresh_until: self.fresh_until, + } + } +} + +impl fmt::Debug for AuthoritativeClientStatusEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthoritativeClientStatusEvidence") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_revision", &self.policy_revision) + .field("issuance_id", &"[redacted]") + .field("evaluation_generation", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Relay signer for display-only client binding status. +pub struct RelayClientBindingStatusIssuer<'a> { + relay_keys: &'a Keys, + revisions: &'a dyn DurableClientStatusRevisionSource, + privacy_key: &'a ClientStatusPrivacyKey, +} + +impl<'a> RelayClientBindingStatusIssuer<'a> { + /// Bind a relay signing key and externally durable revision source. + pub const fn new( + relay_keys: &'a Keys, + revisions: &'a dyn DurableClientStatusRevisionSource, + privacy_key: &'a ClientStatusPrivacyKey, + ) -> Self { + Self { + relay_keys, + revisions, + privacy_key, + } + } + + /// Sign a generic withdrawal without exposing its lifecycle cause. + pub async fn issue_withdrawn( + &self, + evidence: &AuthoritativeClientStatusEvidence, + receipt: &ClientStatusIssuanceReceipt, + ) -> Result { + if receipt.scope != evidence.revision_scope() { + return Err(RelayClientStatusError::IssuanceReceiptMismatch); + } + let withdrawal_fingerprint = withdrawal_fingerprint(receipt); + let revision = self + .revisions + .withdrawal_revision_for(receipt, withdrawal_fingerprint) + .await + .ok_or(RelayClientStatusError::RevisionUnavailable)?; + if revision.revision() <= receipt.revision { + return Err(RelayClientStatusError::RevisionDidNotAdvance); + } + ClientBindingStatusInputV1::withdrawn( + evidence.authorization_domain, + evidence.event_author_pubkey, + revision.revision(), + evidence.issued_at, + evidence.fresh_until, + )? + .sign_with_relay_keys(self.relay_keys) + .map_err(Into::into) + } + + async fn issue_current( + &self, + evidence: &AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, + ) -> Result<(Event, u64), RelayClientStatusError> { + let issuance_fingerprint = current_issuance_fingerprint(evidence, label); + let revision = self + .revisions + .current_revision_for(&evidence.current_requirement(), issuance_fingerprint) + .await + .ok_or(RelayClientStatusError::RevisionUnavailable)?; + let input = ClientBindingStatusInputV1::current( + evidence.authorization_domain, + evidence.event_author_pubkey, + evidence.binding_version.get(), + evidence.policy_revision.as_str(), + revision.revision(), + evidence.issued_at, + evidence.fresh_until, + label.map(|value| value.as_str().to_string()), + )?; + let event = input + .sign_with_relay_keys(self.relay_keys) + .map_err(RelayClientStatusError::from)?; + Ok((event, revision.revision())) + } +} + +fn current_issuance_fingerprint( + evidence: &AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-client-status-current-issuance-v2"); + digest.update(evidence.authorization_domain.as_uuid().as_bytes()); + digest.update(evidence.event_author_pubkey.to_bytes()); + digest.update(evidence.binding_id.as_bytes()); + digest.update(evidence.binding_version.get().to_be_bytes()); + let profile = evidence.profile_id.as_str().as_bytes(); + digest.update((profile.len() as u64).to_be_bytes()); + digest.update(profile); + let policy = evidence.policy_version.as_str().as_bytes(); + digest.update((policy.len() as u64).to_be_bytes()); + digest.update(policy); + digest.update(evidence.issuance_id.as_bytes()); + digest.update(evidence.evaluation_generation.to_be_bytes()); + digest.update(evidence.issued_at.to_be_bytes()); + digest.update(evidence.fresh_until.to_be_bytes()); + if let Some(label) = label { + digest.update([1]); + let label = label.as_str().as_bytes(); + digest.update((label.len() as u64).to_be_bytes()); + digest.update(label); + } else { + digest.update([0]); + } + digest.finalize().into() +} + +fn withdrawal_fingerprint(receipt: &ClientStatusIssuanceReceipt) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-client-status-withdrawal-v1"); + digest.update(receipt.scope.authorization_domain().as_uuid().as_bytes()); + digest.update(receipt.scope.event_author_pubkey().to_bytes()); + // Every authenticated connection showing this author shares one durable + // withdrawal allocation. The connection remains a delivery target only. + digest.update(receipt.revision.to_be_bytes()); + digest.update(receipt.issuance_fingerprint); + digest.finalize().into() +} + +impl fmt::Debug for RelayClientBindingStatusIssuer<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayClientBindingStatusIssuer") + .field("relay_keys", &"[redacted]") + .field("revisions", &"[injected]") + .field("privacy_key", &"[redacted]") + .finish() + } +} + +/// Opaque permission to expose a status on the dedicated authenticated path. +/// +/// This type has no unchecked constructor. Complete external gate evidence is +/// required; ordinary event ingest and pub/sub are never valid substitutes. +pub struct ClientStatusPresentationPermit { + _private: (), +} + +/// Deployment-owned evidence that the RFC presentation gate and exact client +/// contract have both been approved for one reviewed revision. +pub trait CompleteClientStatusPresentationApproval: Send + Sync { + /// Exact lowercase Git revision reviewed by every presentation gate. + fn reviewed_implementation_revision(&self) -> &str; + + /// Whether the applicable RFC presentation/privacy gate passed. + fn presentation_gate_passed(&self) -> bool; + + /// Whether the dedicated client transport contract passed end to end. + fn dedicated_client_contract_passed(&self) -> bool; +} + +impl ClientStatusPresentationPermit { + /// Construct the otherwise unavailable permit from complete external gate + /// evidence. There is intentionally no environment/boolean constructor. + pub fn from_complete_stack( + approval: &dyn CompleteClientStatusPresentationApproval, + ) -> Result { + let revision = approval.reviewed_implementation_revision(); + if revision.len() != 40 + || !revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !approval.presentation_gate_passed() + || !approval.dedicated_client_contract_passed() + { + return Err(ClientStatusPresentationGateError::Incomplete); + } + Ok(Self { _private: () }) + } +} + +impl fmt::Debug for ClientStatusPresentationPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusPresentationPermit") + .finish_non_exhaustive() + } +} + +/// One relay-authenticated status targeted to an exact connection scope. +/// +/// The transport implementation must deliver only to the authenticated +/// connection for `authorization_domain` and `event_author_pubkey`. It must +/// not route through event ingestion, storage, subscriptions, or pub/sub. +pub struct DedicatedClientStatusDelivery<'a> { + event: &'a Event, + relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_id: Uuid, +} + +impl DedicatedClientStatusDelivery<'_> { + /// Relay-signed ephemeral status event. + pub const fn event(&self) -> &Event { + self.event + } + + /// Relay key against which the transport must authenticate the event. + pub const fn relay_pubkey(&self) -> PublicKey { + self.relay_pubkey + } + + /// Server-resolved authorization domain of the target connection. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact authenticated event-author key of the target connection. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Exact server-owned authenticated connection target. + pub const fn connection_id(&self) -> Uuid { + self.connection_id + } +} + +impl fmt::Debug for DedicatedClientStatusDelivery<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DedicatedClientStatusDelivery") + .field("event", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_id", &"[redacted]") + .finish() + } +} + +/// Dedicated relay-authenticated status channel. +/// +/// Implementations become reachable only behind the separately approved RFC +/// presentation gate. This trait must never be implemented by ordinary event +/// ingest, persistence, subscription, or pub/sub paths. +pub trait DedicatedClientStatusTransport: Send + Sync { + /// Deliver one status only to its exact authenticated connection scope. + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError>; +} + +/// Result of one current-status delivery attempt. +/// +/// The durable receipt is retained even when the transport reports failure, +/// because the effect may have become visible before its acknowledgement was +/// lost. Callers must register the receipt for invalidation reconciliation. +pub struct ClientStatusDeliveryAttempt { + event: Event, + receipt: ClientStatusIssuanceReceipt, + delivery_error: Option, +} + +impl ClientStatusDeliveryAttempt { + /// Relay-signed current status attempted on the dedicated connection. + pub const fn event(&self) -> &Event { + &self.event + } + + /// Durable receipt that must remain withdrawable after any delivery result. + pub const fn receipt(&self) -> &ClientStatusIssuanceReceipt { + &self.receipt + } + + /// Dedicated transport result; failure can be ambiguous after visibility. + pub const fn delivery_error(&self) -> Option { + self.delivery_error + } + + /// Consume the attempt while retaining its exact withdrawal receipt. + pub fn into_receipt(self) -> ClientStatusIssuanceReceipt { + self.receipt + } +} + +/// Dedicated exact-connection transport backed by the live connection +/// manager. It bypasses event ingest, storage, subscriptions, and pub/sub. +pub struct ConnectionManagerClientStatusTransport { + connections: Arc, +} + +impl ConnectionManagerClientStatusTransport { + /// Bind the server-owned connection registry. + pub fn new(connections: Arc) -> Self { + Self { connections } + } +} + +impl DedicatedClientStatusTransport for ConnectionManagerClientStatusTransport { + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + if self + .connections + .community_for_conn(delivery.connection_id()) + != Some(delivery.authorization_domain()) + || self + .connections + .pubkey_for(delivery.connection_id()) + .as_deref() + != Some(delivery.event_author_pubkey().as_bytes()) + { + return Err(DedicatedClientStatusTransportError::Unavailable); + } + let frame = crate::protocol::RelayMessage::event( + "__buzz_client_binding_status_v1__", + delivery.event(), + ); + self.connections + .send_to(delivery.connection_id(), frame) + .then_some(()) + .ok_or(DedicatedClientStatusTransportError::Unavailable) + } +} + +/// Installed, opt-in production presentation runtime. +/// +/// Construction requires the complete external gate. The stock OSS binary +/// never creates this value, so presentation remains disabled by default. +pub struct ProductionClientStatusRuntime { + permit: Arc, + privacy_key: ClientStatusPrivacyKey, + transport: Arc, +} + +impl ProductionClientStatusRuntime { + /// Bind approved presentation evidence to a dedicated transport. + pub fn new( + permit: ClientStatusPresentationPermit, + privacy_key: ClientStatusPrivacyKey, + transport: Arc, + ) -> Self { + Self { + permit: Arc::new(permit), + privacy_key, + transport, + } + } + + /// Evaluate, issue, and register withdrawal for one authenticated direct + /// connection. Failure withholds presentation and never changes access. + pub async fn present_after_auth( + self: &Arc, + state: Arc, + proof: Arc, + assertion: Arc, + connection_id: Uuid, + connection_cancellation: CancellationToken, + ) -> Result<(), ClientStatusRuntimeError> { + let protected = state + .protected_transport() + .ok_or(ClientStatusRuntimeError::ProtectedRuntimeUnavailable)?; + // Presentation invalidation must withdraw the indicator without + // becoming connection authority in VerifyOnly. Enforce retains its + // independent protected-session cancellation fence. + let presentation_cancellation = CancellationToken::new(); + let session_target = state + .conn_manager + .authorization_session_target(connection_id) + .ok_or(super::transport::ProtectedTransportError::InvalidSessionId)?; + let request = super::transport::ProtectedOperationRequest::new_with_cancellation( + proof, + Some(assertion), + buzz_auth::AuthorizationCapability::CommunityRead, + Uuid::new_v4(), + "client.status.current", + Some(session_target), + Some(presentation_cancellation.clone()), + )?; + let Some(resolution) = protected.present_status(&request).await? else { + return Ok(()); + }; + let (disposition, observer, evaluation_generation) = resolution.into_parts(); + observer + .observe_current() + .map_err(|_| ClientStatusRuntimeError::StatusStale)?; + let evidence = AuthoritativeClientStatusEvidence::from_verification_only( + &disposition, + &self.privacy_key, + evaluation_generation, + )?; + let restore = state + .restore_protection() + .cloned() + .ok_or(ClientStatusRuntimeError::ProtectedRuntimeUnavailable)?; + let revisions = PostgresClientStatusRevisionSource::new(state.db.clone(), restore); + let issuer = RelayClientBindingStatusIssuer::new( + &state.relay_keypair, + &revisions, + &self.privacy_key, + ); + let attempt = issuer + .deliver_verification_only( + &self.permit, + &disposition, + evaluation_generation, + None, + connection_id, + self.transport.as_ref(), + ) + .await?; + let delivery_failed = attempt.delivery_error().is_some(); + let receipt = attempt.into_receipt(); + let runtime = Arc::clone(self); + tokio::spawn(async move { + let now = nostr::Timestamp::now().as_secs(); + let expiry_delay = Duration::from_secs(disposition.expires_at().saturating_sub(now)); + let invalidated = tokio::select! { + _ = connection_cancellation.cancelled() => false, + _ = presentation_cancellation.cancelled() => true, + _ = tokio::time::sleep(expiry_delay) => { + // Status freshness is exclusive and clients clear locally at + // this bound. Dropping the observer prevents any extension. + false + } + }; + if invalidated { + if let Some(restore) = state.restore_protection().cloned() { + let revisions = + PostgresClientStatusRevisionSource::new(state.db.clone(), restore); + let issuer = RelayClientBindingStatusIssuer::new( + &state.relay_keypair, + &revisions, + &runtime.privacy_key, + ); + let _ = issuer + .deliver_withdrawn_after_invalidation( + &runtime.permit, + &evidence, + &receipt, + runtime.transport.as_ref(), + ) + .await; + } + } + drop(observer); + }); + if delivery_failed { + return Err(ClientStatusRuntimeError::DeliveryUnavailable); + } + Ok(()) + } +} + +impl fmt::Debug for ClientStatusDeliveryAttempt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusDeliveryAttempt") + .field("event", &"[redacted]") + .field("receipt", &self.receipt) + .field("delivery_error", &self.delivery_error) + .finish() + } +} + +impl RelayClientBindingStatusIssuer<'_> { + /// Issue and deliver verification-only status through the typed gate. + /// + /// Returning the delivery future preserves the event-storage-agnostic + /// public contract while durable revision allocation remains an injected + /// asynchronous implementation detail. + pub fn issue_verification_only<'a>( + &'a self, + permit: &'a ClientStatusPresentationPermit, + disposition: &'a VerificationOnlyDisposition, + evaluation_generation: u64, + label: Option<&'a PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &'a dyn DedicatedClientStatusTransport, + ) -> impl std::future::Future> + + 'a { + self.deliver_verification_only( + permit, + disposition, + evaluation_generation, + label, + connection_id, + transport, + ) + } + + async fn deliver_current( + &self, + evidence: AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + let issuance_fingerprint = current_issuance_fingerprint(&evidence, label); + let (event, revision) = self.issue_current(&evidence, label).await?; + let delivery_error = transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: self.relay_keys.public_key(), + authorization_domain: evidence.authorization_domain, + event_author_pubkey: evidence.event_author_pubkey, + connection_id, + }) + .err(); + Ok(ClientStatusDeliveryAttempt { + event, + receipt: ClientStatusIssuanceReceipt { + scope: evidence.revision_scope(), + connection_id, + revision, + issuance_fingerprint, + }, + delivery_error, + }) + } + + /// Issue and deliver a verification-only status on the dedicated path. + /// + /// The production runtime can reach this only after injected complete + /// presentation approval constructs `permit`. It never creates a client + /// route or weakens verification-only authorization semantics. + pub async fn deliver_verification_only( + &self, + _permit: &ClientStatusPresentationPermit, + disposition: &VerificationOnlyDisposition, + evaluation_generation: u64, + label: Option<&PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + let evidence = AuthoritativeClientStatusEvidence::from_verification_only( + disposition, + self.privacy_key, + evaluation_generation, + )?; + self.deliver_current(evidence, label, connection_id, transport) + .await + } + + /// Issue an opaque, strictly newer withdrawal after invalidation and + /// deliver it only to the exact authenticated connection. Without an + /// externally approved and installed presentation runtime, this path + /// remains unreachable. + pub async fn deliver_withdrawn_after_invalidation( + &self, + _permit: &ClientStatusPresentationPermit, + evidence: &AuthoritativeClientStatusEvidence, + receipt: &ClientStatusIssuanceReceipt, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + if receipt.connection_id.is_nil() { + return Err(RelayClientStatusError::IssuanceReceiptMismatch); + } + let event = self.issue_withdrawn(evidence, receipt).await?; + transport.deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: self.relay_keys.public_key(), + authorization_domain: evidence.authorization_domain, + event_author_pubkey: evidence.event_author_pubkey, + connection_id: receipt.connection_id, + })?; + Ok(event) + } +} + +/// Opaque dedicated-transport failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum DedicatedClientStatusTransportError { + /// Exact authenticated connection delivery was unavailable. + #[error("dedicated client-status transport is unavailable")] + Unavailable, +} + +/// Incomplete client-presentation approval evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusPresentationGateError { + /// Revision, RFC gate, or dedicated client contract was incomplete. + #[error("client-status presentation approval is incomplete")] + Incomplete, +} + +/// Fail-closed production client-status result. These failures withhold only +/// presentation and never alter access policy. +#[derive(Debug, Error)] +pub enum ClientStatusRuntimeError { + /// The protected/restore runtime was not completely installed. + #[error("client-status protected runtime is unavailable")] + ProtectedRuntimeUnavailable, + /// Dedicated delivery failed or became ambiguous. + #[error("client-status delivery is unavailable")] + DeliveryUnavailable, + /// Current authority changed after evaluation and before delivery. + #[error("client-status authority is stale")] + StatusStale, + /// Protected status evaluation failed. + #[error(transparent)] + Protected(#[from] super::transport::ProtectedTransportError), + /// Status construction, revision allocation, or signing failed. + #[error(transparent)] + Status(#[from] RelayClientStatusError), + /// Privacy transform initialization failed. + #[error(transparent)] + Privacy(#[from] ClientStatusPrivacyError), +} + +/// Invalid durable revision/floor state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusRevisionError { + /// Revision zero cannot be issued. + #[error("durable client-status revision must be positive")] + ZeroRevision, + /// Reconciliation returned a revision below its durable floor. + #[error("durable client-status revision is below its floor")] + BelowDurableFloor, +} + +/// Invalid privacy-approved label configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum PrivacyApprovedClientStatusLabelError { + /// The configured label was empty, unsafe, or exceeded its public bound. + #[error("privacy-approved client-status label is invalid")] + InvalidLabel, +} + +/// Failure to initialize the keyed client-status privacy transform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusPrivacyError { + /// The injected privacy key could not initialize the HMAC primitive. + #[error("client-status privacy key material is invalid")] + InvalidKeyMaterial, +} + +/// Fail-closed status issuance error. +#[derive(Debug, Error)] +pub enum RelayClientStatusError { + /// Durable status revision/floor state was unavailable. + #[error("durable client-status revision is unavailable")] + RevisionUnavailable, + /// A withdrawal revision did not supersede the last current status. + #[error("durable client-status withdrawal revision did not advance")] + RevisionDidNotAdvance, + /// Withdrawal did not name the exact delivered current status and connection. + #[error("client-status issuance receipt does not match the withdrawal scope")] + IssuanceReceiptMismatch, + /// The provider-neutral revision could not be derived safely. + #[error(transparent)] + Privacy(#[from] ClientStatusPrivacyError), + /// Dedicated relay-authenticated delivery failed. + #[error(transparent)] + DedicatedTransport(#[from] DedicatedClientStatusTransportError), + /// Core status representation was invalid. + #[error(transparent)] + InvalidStatus(#[from] ClientBindingStatusError), + /// Event construction or signing failed. + #[error(transparent)] + Build(#[from] ClientBindingStatusBuildError), +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::AtomicU8; + use std::sync::Mutex; + + use buzz_auth::{AuthorizationProfileId, PolicyVersion}; + use buzz_core::client_binding_status::{ + validate_client_binding_status_event, ClientBindingStatusDisposition, + }; + use uuid::Uuid; + + use super::*; + + const ISSUED_AT: u64 = 1_800_000_000; + type ObservedCurrentRequirement = (Uuid, u64, String, String, u64); + type ObservedDedicatedDelivery = (nostr::EventId, CommunityId, PublicKey, PublicKey, Uuid); + + struct SyntheticRevisions { + value: Option, + seen: Mutex>, + requirements: Mutex>, + } + + struct SyntheticDedicatedTransport { + deliveries: Mutex>, + } + + struct FailingDedicatedTransport; + + struct SyntheticPresentationApproval { + revision: &'static str, + presentation: bool, + client: bool, + } + + impl CompleteClientStatusPresentationApproval for SyntheticPresentationApproval { + fn reviewed_implementation_revision(&self) -> &str { + self.revision + } + + fn presentation_gate_passed(&self) -> bool { + self.presentation + } + + fn dedicated_client_contract_passed(&self) -> bool { + self.client + } + } + + impl DedicatedClientStatusTransport for FailingDedicatedTransport { + fn deliver( + &self, + _delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + Err(DedicatedClientStatusTransportError::Unavailable) + } + } + + impl DedicatedClientStatusTransport for SyntheticDedicatedTransport { + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + self.deliveries.lock().expect("synthetic lock").push(( + delivery.event().id, + delivery.authorization_domain(), + delivery.event_author_pubkey(), + delivery.relay_pubkey(), + delivery.connection_id(), + )); + Ok(()) + } + } + + #[async_trait] + impl DurableClientStatusRevisionSource for SyntheticRevisions { + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + _issuance_fingerprint: [u8; 32], + ) -> Option { + let scope = requirement.scope(); + self.seen.lock().expect("synthetic lock").push(scope); + self.requirements.lock().expect("synthetic lock").push(( + requirement.binding_id(), + requirement.binding_version().get(), + requirement.profile_id().as_str().to_owned(), + requirement.policy_version().as_str().to_owned(), + requirement.fresh_until(), + )); + self.value + } + + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + _withdrawal_fingerprint: [u8; 32], + ) -> Option { + self.seen + .lock() + .expect("synthetic lock") + .push(receipt.scope); + self.value + } + } + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(7)) + } + + fn privacy_key() -> ClientStatusPrivacyKey { + ClientStatusPrivacyKey::from_secret([0x51; 32]) + } + + fn policy_revision() -> ProviderNeutralPolicyRevision { + let profile = AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"); + let policy = + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"); + ProviderNeutralPolicyRevision::derive(&privacy_key(), &profile, &policy) + .expect("synthetic privacy key derives a revision") + } + + fn evidence(author: PublicKey) -> AuthoritativeClientStatusEvidence { + AuthoritativeClientStatusEvidence::from_authoritative_runtime( + domain(), + author, + Uuid::from_u128(0x900), + BindingVersion::new(9).expect("synthetic binding version is valid"), + AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"), + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"), + policy_revision(), + Uuid::from_u128(0x901), + 1, + ISSUED_AT, + ISSUED_AT + 120, + ) + } + + fn receipt(author: PublicKey, revision: u64) -> ClientStatusIssuanceReceipt { + ClientStatusIssuanceReceipt { + scope: ClientStatusRevisionScope { + authorization_domain: domain(), + event_author_pubkey: author, + }, + connection_id: Uuid::from_u128(0x902), + revision, + issuance_fingerprint: [0x45; 32], + } + } + + fn revisions(value: Option) -> SyntheticRevisions { + SyntheticRevisions { + value, + seen: Mutex::new(Vec::new()), + requirements: Mutex::new(Vec::new()), + } + } + + #[test] + fn durable_revision_validates_floor() { + assert_eq!( + DurableClientStatusRevision::from_durable_state(0, 0), + Err(ClientStatusRevisionError::ZeroRevision) + ); + assert_eq!( + DurableClientStatusRevision::from_durable_state(8, 9), + Err(ClientStatusRevisionError::BelowDurableFloor) + ); + let revision = DurableClientStatusRevision::from_durable_state(9, 9) + .expect("revision at its floor is valid"); + assert_eq!(revision.revision(), 9); + assert_eq!(revision.floor(), 9); + } + + #[test] + fn presentation_permit_requires_one_exact_complete_revision() { + let complete = SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: true, + client: true, + }; + assert!(ClientStatusPresentationPermit::from_complete_stack(&complete).is_ok()); + + for incomplete in [ + SyntheticPresentationApproval { + revision: "not-a-revision", + presentation: true, + client: true, + }, + SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: false, + client: true, + }, + SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: true, + client: false, + }, + ] { + assert!(matches!( + ClientStatusPresentationPermit::from_complete_stack(&incomplete), + Err(ClientStatusPresentationGateError::Incomplete) + )); + } + } + + #[tokio::test] + async fn withdrawal_uses_exact_scope_and_omits_current_state() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 11) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 11); + let event = issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await + .expect("withdrawal event signs"); + let status = validate_client_binding_status_event( + &event, + &relay.public_key(), + domain(), + &author.public_key(), + ISSUED_AT, + ) + .expect("signed status validates"); + + assert_eq!(status.status_revision(), 12); + assert_eq!(status.binding_version(), None); + assert_eq!(status.policy_version(), None); + assert!(!event.content.contains("private-provider-policy-value")); + assert!(!event.content.contains("synthetic-profile")); + assert_eq!( + status.disposition(), + ClientBindingStatusDisposition::Withdrawn + ); + + let seen = source.seen.lock().expect("synthetic lock"); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].authorization_domain(), domain()); + assert_eq!(seen[0].event_author_pubkey(), author.public_key()); + } + + #[tokio::test] + async fn missing_durable_revision_withholds_all_output() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(None); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 1); + assert!(matches!( + issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await, + Err(RelayClientStatusError::RevisionUnavailable) + )); + } + + #[tokio::test] + async fn current_issuance_revalidates_exact_private_binding_state() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 12) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + issuer + .issue_current(&evidence(author.public_key()), None) + .await + .expect("exact current binding signs"); + + let requirements = source.requirements.lock().expect("synthetic lock"); + assert_eq!(requirements.len(), 1); + let requirement = &requirements[0]; + assert_eq!(requirement.0, Uuid::from_u128(0x900)); + assert_eq!(requirement.1, 9); + assert_eq!(requirement.2, "synthetic-profile"); + assert_eq!(requirement.3, "private-provider-policy-value"); + assert_eq!(requirement.4, ISSUED_AT + 120); + } + + #[tokio::test] + async fn withdrawal_must_strictly_advance() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 12) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 12); + assert!(matches!( + issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await, + Err(RelayClientStatusError::RevisionDidNotAdvance) + )); + } + + #[test] + fn privacy_label_is_configuration_only_and_current_only() { + assert!(PrivacyApprovedClientStatusLabel::from_server_configuration("").is_err()); + assert!(PrivacyApprovedClientStatusLabel::from_server_configuration(" private").is_err()); + let label = PrivacyApprovedClientStatusLabel::from_server_configuration( + "Privacy Approved Enterprise", + ) + .expect("synthetic configured label is valid"); + assert_eq!(label.as_str(), "Privacy Approved Enterprise"); + assert_eq!( + format!("{label:?}"), + "PrivacyApprovedClientStatusLabel(\"[redacted]\")" + ); + } + + #[test] + fn policy_revision_is_keyed_and_unlinkable_across_privacy_keys() { + let profile = AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"); + let policy = + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"); + let first = ProviderNeutralPolicyRevision::derive( + &ClientStatusPrivacyKey::from_secret([0x11; 32]), + &profile, + &policy, + ) + .expect("first synthetic key derives a revision"); + let second = ProviderNeutralPolicyRevision::derive( + &ClientStatusPrivacyKey::from_secret([0x22; 32]), + &profile, + &policy, + ) + .expect("second synthetic key derives a revision"); + + assert_eq!(first.as_str().len(), 64); + assert_eq!(second.as_str().len(), 64); + assert_ne!(first, second); + for revision in [first.as_str(), second.as_str()] { + assert!(!revision.contains(profile.as_str())); + assert!(!revision.contains(policy.as_str())); + } + assert_eq!( + format!("{:?}", ClientStatusPrivacyKey::from_secret([0x33; 32])), + "ClientStatusPrivacyKey(\"[redacted]\")" + ); + } + + #[tokio::test] + async fn dedicated_transport_contract_is_exact_scope_and_test_only_permitted() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(14, 14) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 13); + let event = issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await + .expect("synthetic gated status signs"); + let permit = ClientStatusPresentationPermit { _private: () }; + let connection_id = Uuid::new_v4(); + let transport = SyntheticDedicatedTransport { + deliveries: Mutex::new(Vec::new()), + }; + + transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: domain(), + event_author_pubkey: author.public_key(), + connection_id, + }) + .expect("synthetic dedicated delivery succeeds"); + + let deliveries = transport.deliveries.lock().expect("synthetic lock"); + assert_eq!( + deliveries.as_slice(), + &[( + event.id, + domain(), + author.public_key(), + relay.public_key(), + connection_id, + )] + ); + assert!(format!("{permit:?}").starts_with("ClientStatusPresentationPermit")); + } + + #[tokio::test] + async fn production_transport_targets_only_the_exact_authenticated_connection() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(14, 14) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let event = issuer + .issue_withdrawn( + &evidence(author.public_key()), + &receipt(author.public_key(), 13), + ) + .await + .expect("synthetic withdrawal signs"); + + let connections = Arc::new(crate::state::ConnectionManager::new()); + let connection_id = Uuid::new_v4(); + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(2); + connections.register( + connection_id, + tx, + ctrl_tx, + CancellationToken::new(), + domain(), + Arc::new(AtomicU8::new(0)), + Arc::new(tokio::sync::Mutex::new(HashMap::new())), + 3, + ); + connections + .set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); + let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); + transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: domain(), + event_author_pubkey: author.public_key(), + connection_id, + }) + .expect("exact authenticated connection accepts status"); + let outbound = rx.recv().await.expect("dedicated frame is queued"); + let axum::extract::ws::Message::Text(frame) = outbound.message else { + panic!("dedicated status must be a text frame"); + }; + assert!(frame.as_str().contains("__buzz_client_binding_status_v1__")); + assert!(frame.as_str().contains(&event.id.to_string())); + + for (wrong_domain, wrong_author) in [ + ( + CommunityId::from_uuid(Uuid::from_u128(8)), + author.public_key(), + ), + (domain(), Keys::generate().public_key()), + ] { + assert_eq!( + transport.deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: wrong_domain, + event_author_pubkey: wrong_author, + connection_id, + }), + Err(DedicatedClientStatusTransportError::Unavailable) + ); + } + assert!(rx.try_recv().is_err(), "wrong scopes emit no frame"); + } + + #[tokio::test] + async fn ambiguous_current_delivery_retains_withdrawable_receipt() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(21, 21) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let connection_id = Uuid::new_v4(); + let attempt = issuer + .deliver_current( + evidence(author.public_key()), + None, + connection_id, + &FailingDedicatedTransport, + ) + .await + .expect("durable allocation and signing succeed"); + + assert_eq!(attempt.receipt().connection_id(), connection_id); + assert_eq!(attempt.receipt().revision(), 21); + assert_eq!( + attempt.delivery_error(), + Some(DedicatedClientStatusTransportError::Unavailable) + ); + assert_eq!(attempt.event().pubkey, relay.public_key()); + } + + #[tokio::test] + async fn invalidation_withdraws_every_exact_connection_receipt() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(31, 31) + .expect("synthetic withdrawal revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let permit = ClientStatusPresentationPermit { _private: () }; + let first_connection = Uuid::new_v4(); + let second_connection = Uuid::new_v4(); + let mut first = receipt(author.public_key(), 30); + first.connection_id = first_connection; + let mut second = receipt(author.public_key(), 30); + second.connection_id = second_connection; + let transport = SyntheticDedicatedTransport { + deliveries: Mutex::new(Vec::new()), + }; + + issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence(author.public_key()), + &first, + &transport, + ) + .await + .expect("first exact connection withdrawal"); + issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence(author.public_key()), + &second, + &transport, + ) + .await + .expect("second exact connection withdrawal"); + + let targets = transport + .deliveries + .lock() + .expect("synthetic lock") + .iter() + .map(|delivery| delivery.4) + .collect::>(); + assert_eq!(targets, vec![first_connection, second_connection]); + } + + #[test] + fn current_issuance_fingerprint_frames_variable_fields() { + let author = Keys::generate().public_key(); + let mut first = evidence(author); + first.profile_id = + AuthorizationProfileId::from_server_configuration("a").expect("first profile"); + first.policy_version = PolicyVersion::new("bc").expect("first policy"); + let mut second = evidence(author); + second.profile_id = + AuthorizationProfileId::from_server_configuration("ab").expect("second profile"); + second.policy_version = PolicyVersion::new("c").expect("second policy"); + + assert_ne!( + current_issuance_fingerprint(&first, None), + current_issuance_fingerprint(&second, None) + ); + } + + #[test] + fn no_public_api_can_sign_current_status_without_a_receipt() { + let source = include_str!("status.rs"); + let production = source + .split("#[cfg(test)]") + .next() + .expect("production section exists"); + assert!(!production.contains("pub async fn issue_verification_only")); + assert!(!production.contains("pub async fn issue_current")); + } + + #[test] + fn production_module_has_no_authority_or_delivery_dependency() { + let source = include_str!("status.rs"); + let production = source + .split("#[cfg(test)]") + .next() + .expect("production section exists"); + for forbidden in [ + "AuthContext", + "AuthorizationLease", + "CapabilitySet", + "buzz_pubsub", + "handlers::", + "KIND_USER_TRUSTED_ASSERTION", + "corporate_identity", + ] { + assert!( + !production.contains(forbidden), + "status adapter gained forbidden dependency {forbidden}" + ); + } + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/status/postgres.rs b/crates/buzz-relay/src/authorization_runtime/status/postgres.rs new file mode 100644 index 0000000000..891d844b3f --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/status/postgres.rs @@ -0,0 +1,155 @@ +//! Durable PostgreSQL implementation behind the storage-agnostic status seam. + +use async_trait::async_trait; +use uuid::Uuid; + +use super::{ + ClientStatusCurrentRequirement, ClientStatusIssuanceReceipt, ClientStatusRevisionScope, + DurableClientStatusRevision, DurableClientStatusRevisionSource, +}; + +/// PostgreSQL-backed revision source coupled to the independent restore witness. +/// +/// Construction does not enable presentation; the unconstructible presentation +/// permit remains the separate runtime gate. +pub struct PostgresClientStatusRevisionSource { + db: buzz_db::Db, + restore: std::sync::Arc, +} + +impl PostgresClientStatusRevisionSource { + /// Bind the writer database and the exact initialized restore runtime. + pub fn new( + db: buzz_db::Db, + restore: std::sync::Arc, + ) -> Self { + Self { db, restore } + } + + async fn reconcile_allocation( + &self, + scope: ClientStatusRevisionScope, + operation_id: Uuid, + request_fingerprint: [u8; 32], + result: Result< + buzz_db::client_status::AllocatedStatusRevision, + buzz_db::client_status::ClientStatusAllocationError, + >, + witness: super::super::restore::RestoreMutationGuard, + ) -> Option { + match result { + Ok(revision) => { + witness.commit().await.ok()?; + DurableClientStatusRevision::from_durable_state(revision.revision, revision.floor) + .ok() + } + Err(buzz_db::client_status::ClientStatusAllocationError::CommitUnknown(_)) => { + witness.commit().await.ok()?; + let revision = self + .db + .committed_status_revision( + scope.authorization_domain(), + operation_id, + request_fingerprint, + ) + .await + .ok()??; + DurableClientStatusRevision::from_durable_state(revision.revision, revision.floor) + .ok() + } + Err(_) => { + let _ = witness.abort().await; + None + } + } + } +} + +#[async_trait] +impl DurableClientStatusRevisionSource for PostgresClientStatusRevisionSource { + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + issuance_fingerprint: [u8; 32], + ) -> Option { + let scope = requirement.scope(); + let operation_id = super::super::executor::ProtectedOperationId::derive( + scope.authorization_domain(), + "client.status.current.v1", + &issuance_fingerprint, + ) + .ok()? + .as_uuid(); + let witness = self + .restore + .begin( + scope.authorization_domain(), + operation_id, + issuance_fingerprint, + ) + .await + .ok()?; + let event_author_pubkey = scope.event_author_pubkey().to_bytes(); + let result = self + .db + .allocate_current_status_revision(buzz_db::client_status::CurrentStatusAllocation { + community_id: scope.authorization_domain(), + event_author_pubkey: &event_author_pubkey, + binding_id: requirement.binding_id(), + binding_version: requirement.binding_version().get(), + policy_version: requirement.policy_version().as_str(), + evaluation_generation: requirement.evaluation_generation(), + fresh_until: requirement.fresh_until(), + operation_id, + request_fingerprint: issuance_fingerprint, + }) + .await; + self.reconcile_allocation(scope, operation_id, issuance_fingerprint, result, witness) + .await + } + + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + withdrawal_fingerprint: [u8; 32], + ) -> Option { + let operation_id = super::super::executor::ProtectedOperationId::derive( + receipt.scope.authorization_domain(), + "client.status.withdraw.v1", + &withdrawal_fingerprint, + ) + .ok()? + .as_uuid(); + let witness = self + .restore + .begin( + receipt.scope.authorization_domain(), + operation_id, + withdrawal_fingerprint, + ) + .await + .ok()?; + let event_author_pubkey = receipt.scope.event_author_pubkey().to_bytes(); + let result = self + .db + .allocate_withdrawn_status_revision( + buzz_db::client_status::WithdrawalStatusAllocation { + community_id: receipt.scope.authorization_domain(), + event_author_pubkey: &event_author_pubkey, + supersedes_revision: receipt.revision, + issuance_fingerprint: receipt.issuance_fingerprint, + operation_id, + request_fingerprint: withdrawal_fingerprint, + }, + ) + .await; + self.reconcile_allocation( + receipt.scope, + operation_id, + withdrawal_fingerprint, + result, + witness, + ) + .await + } +} 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..e8850e569c --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -0,0 +1,1470 @@ +//! 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 buzz_db::authorization_invalidation::AuthorizationSessionTarget; +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 or DenyProtected: the protected runtime owns the surface 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 | AuthorizationMode::DenyProtected) => { + 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_target: 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_target: Option, + cancellation: Option, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProtectedTransportError::InvalidCorrelationId); + } + 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_target, + 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 + } + + /// Exact server-issued target for a long-lived transport session. + pub const fn session_target(&self) -> Option { + self.session_target + } + + /// 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.protects_surfaces().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::DenyProtected => deny_protected_request(request), + 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), + Some(AuthorizationMode::DenyProtected) => Err(ProtectedTransportError::DenyProtected), + 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::DenyProtected => Err(ProtectedTransportError::DenyProtected), + 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 { + if state.protected_transport().is_none() { + return Ok(ProtectedAuthorization::Legacy); + } + let session_target = state + .conn_manager + .authorization_session_target(session_id) + .filter(|target| target.session_id() == session_id) + .ok_or(ProtectedTransportError::InvalidSessionId)?; + let authority = authorize_exact_session_if_configured( + state, + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + session_target, + cancellation, + ) + .await?; + state + .conn_manager + .retain_protected_session_authority(session_id, &authority); + Ok(authority) +} + +/// Consult the runtime for a server-issued session target that is not managed +/// by the ordinary relay connection registry (for example, protected audio). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn authorize_exact_session_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_target: AuthorizationSessionTarget, + 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_target), + Some(cancellation), + )?; + runtime.authorize(&request).await +} + +/// 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), + Some(AuthorizationMode::DenyProtected) => Err(ProtectedTransportError::DenyProtected), + } +} + +fn deny_protected_request( + request: &ProtectedOperationRequest, +) -> Result { + if let Some(cancellation) = request.cancellation() { + cancellation.cancel(); + } + Err(ProtectedTransportError::DenyProtected) +} + +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, + /// The exact domain is in the explicit fail-safe protected-denial mode. + #[error("protected authorization is unavailable in deny-protected mode")] + DenyProtected, + /// 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 bool; +} + +/// Potentially asynchronous release fence retained until socket drain. +#[async_trait] +pub(crate) trait QueuedOutboundReleaseFence: Send + Sync { + async fn release(&self) -> bool; +} + +struct SyncQueuedReleaseFence { + authority: Arc, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for SyncQueuedReleaseFence { + async fn release(&self) -> bool { + self.authority.release() + } +} + +pub(crate) fn queued_local_authority( + authority: Arc, +) -> Arc { + Arc::new(SyncQueuedReleaseFence { authority }) +} + +#[async_trait] +trait ChannelReadAuthoritySource: Send + Sync { + async fn channel_set_read_authorized( + &self, + community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + actor: &[u8], + ) -> bool; +} + +#[async_trait] +impl ChannelReadAuthoritySource for buzz_db::Db { + async fn channel_set_read_authorized( + &self, + community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + actor: &[u8], + ) -> bool { + buzz_db::Db::channel_set_read_authorized(self, community_id, channel_ids, actor) + .await + .unwrap_or(false) + } +} + +struct ChannelReadReleaseFence { + source: Arc, + community_id: buzz_core::tenant::CommunityId, + channel_ids: Vec, + actor: Vec, + protected: Option>, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for ChannelReadReleaseFence { + async fn release(&self) -> bool { + if self + .protected + .as_ref() + .is_some_and(|authority| authority.revalidate().is_err()) + { + return false; + } + if !self + .source + .channel_set_read_authorized(self.community_id, &self.channel_ids, &self.actor) + .await + { + return false; + } + self.protected + .as_ref() + .is_none_or(|authority| authority.revalidate().is_ok()) + } +} + +/// Retain uncached channel access, plus optional protected identity authority, +/// until the socket writer accepts the queued frame. +pub(crate) fn queued_channel_read_authority( + db: buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + channel_id: Uuid, + actor: Vec, + protected: Option>, +) -> Arc { + queued_channel_set_read_authority(db, community_id, vec![channel_id], actor, protected) +} + +/// Retain uncached access to every channel that can contribute to one +/// aggregate response until the response is released. +pub(crate) fn queued_channel_set_read_authority( + db: buzz_db::Db, + community_id: buzz_core::tenant::CommunityId, + mut channel_ids: Vec, + actor: Vec, + protected: Option>, +) -> Arc { + channel_ids.sort_unstable(); + channel_ids.dedup(); + Arc::new(ChannelReadReleaseFence { + source: Arc::new(db), + community_id, + channel_ids, + actor, + protected, + }) +} + +/// Evaluate the aggregate read fence synchronously with an HTTP response +/// release. WebSocket callers retain the same fence in their outbound queue. +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 { + queued_channel_set_read_authority(db, community_id, channel_ids, actor, protected) + .release() + .await +} + +impl OutboundReleaseFence for crate::authorization_runtime::transport::ProtectedAuthorization { + fn release(&self) -> bool { + self.release_fetched(()).is_ok() + } +} + +struct CombinedReleaseFence { + sender: Arc, + recipient: Arc, +} + +#[async_trait] +impl QueuedOutboundReleaseFence for CombinedReleaseFence { + async fn release(&self) -> bool { + self.sender.release().await + && self.recipient.release().await + && self.sender.release().await + && self.recipient.release().await + } +} + +/// One queued data frame with optional authority retained until socket drain. +pub struct OutboundData { + pub(crate) message: WsMessage, + authority: Option>, +} + +impl OutboundData { + pub(crate) fn plain(message: WsMessage) -> Self { + Self { + message, + authority: None, + } + } + + pub(crate) fn protected( + message: WsMessage, + authority: Arc, + ) -> Self { + Self { + message, + authority: Some(queued_local_authority(authority)), + } + } + + pub(crate) fn protected_pair( + message: WsMessage, + sender: Arc, + recipient: Arc, + ) -> Self { + Self { + message, + authority: Some(Arc::new(CombinedReleaseFence { + sender: queued_local_authority(sender), + recipient: queued_local_authority(recipient), + })), + } + } + + pub(crate) fn guarded( + message: WsMessage, + authority: Arc, + ) -> Self { + Self { + message, + authority: Some(authority), + } + } + + pub(crate) fn guarded_pair( + message: WsMessage, + sender: Arc, + recipient: Arc, + ) -> Self { + Self { + message, + authority: Some(Arc::new(CombinedReleaseFence { sender, recipient })), + } + } + + #[cfg(test)] + fn guarded_for_test(message: WsMessage, authority: Arc) -> Self { + Self::guarded(message, Arc::new(SyncQueuedReleaseFence { authority })) + } + + async fn release(self) -> Option { + match self.authority { + Some(authority) if authority.release().await => Some(self.message), + Some(_) => None, + None => Some(self.message), + } + } +} + /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); @@ -59,14 +283,14 @@ pub struct ConnectionState { pub tenant: TenantContext, /// Remote socket address of the client. pub remote_addr: SocketAddr, - /// Optional corporate identity JWT captured from the WebSocket upgrade request. - pub corporate_identity_jwt: Option, + /// Optional direct identity assertion captured with verified provenance. + pub corporate_identity_assertion: Option, /// Current NIP-42 authentication state. pub auth_state: RwLock, /// Active subscriptions keyed by subscription ID. pub subscriptions: ConnectionSubscriptions, /// Sender for outbound data messages (EVENT, NOTICE, OK, etc.). - pub send_tx: mpsc::Sender, + pub send_tx: mpsc::Sender, /// Sender for outbound control frames (Pong, Close). /// Separate channel with priority drain — if this channel fills too, /// the connection is closed (writer is completely stalled). @@ -88,7 +312,43 @@ impl ConnectionState { /// `grace_limit` occurrences log a warning; sustained backpressure /// cancels the connection to prevent unbounded memory growth. pub fn send(&self, msg: String) -> bool { - match self.send_tx.try_send(WsMessage::Text(msg.into())) { + self.send_data(OutboundData::plain(WsMessage::Text(msg.into()))) + } + + /// Queue a terminal text frame on the priority control channel. + /// + /// Callers may cancel immediately after this returns: the send loop drains + /// control frames before emitting the WebSocket close frame. + pub(crate) fn send_terminal(&self, msg: String) -> bool { + self.ctrl_tx.try_send(WsMessage::Text(msg.into())).is_ok() + } + + /// Queue protected output while retaining its guard through socket drain. + pub fn send_protected( + &self, + msg: String, + authority: Arc, + ) -> bool { + self.send_data(OutboundData::protected( + WsMessage::Text(msg.into()), + authority, + )) + } + + /// Queue output behind an arbitrary asynchronous release fence. + pub(crate) fn send_guarded( + &self, + msg: String, + authority: Arc, + ) -> bool { + self.send_data(OutboundData::guarded( + WsMessage::Text(msg.into()), + authority, + )) + } + + fn send_data(&self, msg: OutboundData) -> bool { + match self.send_tx.try_send(msg) { Ok(_) => { // Successful send resets the grace counter. self.backpressure_count.store(0, Ordering::Relaxed); @@ -122,7 +382,7 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -144,7 +404,7 @@ pub async fn handle_connection( tenant, conn_id, cancel, - corporate_identity_jwt, + corporate_identity_assertion, ) }, ) @@ -158,7 +418,7 @@ async fn handle_active_connection( tenant: TenantContext, conn_id: Uuid, cancel: CancellationToken, - corporate_identity_jwt: Option, + corporate_identity_assertion: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -170,7 +430,7 @@ async fn handle_active_connection( let challenge = generate_challenge(); - let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); + let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); // Control channel for Pong/Close — small capacity, guaranteed delivery // even when the data buffer is full. let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); @@ -182,7 +442,7 @@ async fn handle_active_connection( conn_id, tenant, remote_addr: addr, - corporate_identity_jwt, + corporate_identity_assertion, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), @@ -197,13 +457,13 @@ async fn handle_active_connection( info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); metrics::counter!( "buzz_ws_connections_total", - "community" => conn.tenant.host().to_owned() + "community" => crate::metrics::community_label(conn.tenant.community()) ) .increment(1); let challenge_msg = RelayMessage::auth_challenge(&challenge); if tx - .send(WsMessage::Text(challenge_msg.into())) + .send(OutboundData::plain(WsMessage::Text(challenge_msg.into()))) .await .is_err() { @@ -310,7 +570,7 @@ async fn handle_active_connection( /// treat a full control channel as terminal (Bug 7 fix). async fn send_loop( ws_send: futures_util::stream::SplitSink, - data_rx: mpsc::Receiver, + data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, cancel: CancellationToken, ) { @@ -319,7 +579,7 @@ async fn send_loop( async fn send_loop_inner( mut ws_send: S, - mut data_rx: mpsc::Receiver, + mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, cancel: CancellationToken, ) where @@ -357,16 +617,30 @@ async fn send_loop_inner( break; } } - Some(msg) = data_rx.recv() => { + Some(queued) = data_rx.recv() => { let mut batched = 1usize; - if ws_send.feed(msg).await.is_err() { + if !sink_ready_before_cancellation(&mut ws_send, &cancel).await { + break; + } + let Some(msg) = queued.release().await else { + cancel.cancel(); + break; + }; + if std::pin::Pin::new(&mut ws_send).start_send(msg).is_err() { break; } while batched < MAX_WS_SEND_BATCH { match data_rx.try_recv() { Ok(next) => { - if ws_send.feed(next).await.is_err() { + if !sink_ready_before_cancellation(&mut ws_send, &cancel).await { + return; + } + let Some(next) = next.release().await else { + cancel.cancel(); + return; + }; + if std::pin::Pin::new(&mut ws_send).start_send(next).is_err() { return; } batched += 1; @@ -385,6 +659,19 @@ async fn send_loop_inner( } } +async fn sink_ready_before_cancellation(ws_send: &mut S, cancel: &CancellationToken) -> bool +where + S: Sink + Unpin, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => false, + result = std::future::poll_fn(|cx| std::pin::Pin::new(&mut *ws_send).poll_ready(cx)) => { + result.is_ok() + } + } +} + /// 3 missed pongs → disconnect. /// /// Sends Ping through the control channel so it isn't blocked by a full @@ -703,6 +990,7 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::{Arc, Mutex}; #[derive(Debug, Default)] @@ -777,6 +1065,106 @@ mod tests { } } + struct ScriptedFence(AtomicBool); + + impl OutboundReleaseFence for ScriptedFence { + fn release(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + struct CountingQueuedFence(AtomicUsize); + + #[async_trait] + impl QueuedOutboundReleaseFence for CountingQueuedFence { + async fn release(&self) -> bool { + self.0.fetch_add(1, Ordering::SeqCst); + true + } + } + + #[tokio::test] + async fn combined_release_rechecks_both_sides_after_async_boundaries() { + let sender = Arc::new(CountingQueuedFence(AtomicUsize::new(0))); + let recipient = Arc::new(CountingQueuedFence(AtomicUsize::new(0))); + let fence = CombinedReleaseFence { + sender: sender.clone(), + recipient: recipient.clone(), + }; + + assert!(fence.release().await); + assert_eq!(sender.0.load(Ordering::SeqCst), 2); + assert_eq!(recipient.0.load(Ordering::SeqCst), 2); + } + + struct ScriptedChannelAuthority { + allowed: AtomicBool, + checked: Mutex>, + } + + #[async_trait] + impl ChannelReadAuthoritySource for ScriptedChannelAuthority { + async fn channel_set_read_authorized( + &self, + _community_id: buzz_core::tenant::CommunityId, + channel_ids: &[Uuid], + _actor: &[u8], + ) -> bool { + self.checked + .lock() + .expect("scripted channel checks poisoned") + .extend_from_slice(channel_ids); + self.allowed.load(Ordering::SeqCst) + } + } + + struct ReadinessBarrierSink { + ready: Arc, + polled: Arc, + waker: Arc>>, + state: Arc>, + } + + impl Sink for ReadinessBarrierSink { + 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("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.state + .lock() + .expect("barrier sink poisoned") + .messages + .push(item); + 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) + } + } + fn text_payloads(messages: &[WsMessage]) -> Vec { messages .iter() @@ -806,7 +1194,9 @@ mod tests { let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); for i in 0..5 { data_tx - .send(WsMessage::Text(format!("data-{i}").into())) + .send(OutboundData::plain(WsMessage::Text( + format!("data-{i}").into(), + ))) .await .expect("queue data frame"); } @@ -822,12 +1212,208 @@ mod tests { ); } + #[tokio::test] + async fn protected_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 state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let fence = Arc::new(ScriptedFence(AtomicBool::new(true))); + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::guarded_for_test( + WsMessage::Text("protected".into()), + fence.clone(), + )) + .await + .expect("queue protected frame"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + fence.0.store(false, Ordering::SeqCst); + ready.store(true, Ordering::SeqCst); + waker + .lock() + .expect("barrier waker poisoned") + .take() + .expect("poll_ready registered a waker") + .wake(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + assert!( + state + .lock() + .expect("barrier sink poisoned") + .messages + .is_empty(), + "authority loss while readiness is pending must prevent start_send" + ); + } + + #[tokio::test] + async fn count_release_rechecks_every_contributing_channel() { + let first = Uuid::new_v4(); + let second = Uuid::new_v4(); + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let fence = ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![first, second], + actor: vec![7; 32], + protected: None, + }; + + assert!(fence.release().await); + assert_eq!( + *source + .checked + .lock() + .expect("scripted channel checks poisoned"), + vec![first, second] + ); + } + + #[tokio::test] + async fn net_http_004_count_release_denies_authority_loss_after_fetch() { + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let fence = ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![Uuid::new_v4()], + actor: vec![9; 32], + protected: None, + }; + + // The query has completed. A membership removal or an open-to-private + // transition now makes the authoritative DB check return false. + source.allowed.store(false, Ordering::SeqCst); + assert!(!fence.release().await); + } + + /// NET-WS-009: a COUNT queued while access is valid must not become + /// visible if membership or channel visibility changes while the socket + /// is waiting for sink readiness. + #[tokio::test] + async fn net_ws_009_count_release_denies_authority_loss_before_socket_acceptance() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let source = Arc::new(ScriptedChannelAuthority { + allowed: AtomicBool::new(true), + checked: Mutex::new(Vec::new()), + }); + let release = Arc::new(ChannelReadReleaseFence { + source: source.clone(), + community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()), + channel_ids: vec![Uuid::new_v4()], + actor: vec![10; 32], + protected: None, + }); + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::guarded( + WsMessage::Text(RelayMessage::count("count-race", 1).into()), + release, + )) + .await + .expect("queue protected COUNT"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + source.allowed.store(false, Ordering::SeqCst); + ready.store(true, Ordering::SeqCst); + waker + .lock() + .expect("barrier waker poisoned") + .take() + .expect("poll_ready registered a waker") + .wake(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + assert!( + state + .lock() + .expect("barrier sink poisoned") + .messages + .is_empty(), + "COUNT must not reach start_send after its channel authority is lost" + ); + } + + /// O4-EXP-SESSION-001: session expiry wins over a COUNT that has been + /// computed and queued but has not yet crossed the socket boundary. + #[tokio::test(start_paused = true)] + async fn o4_exp_session_001_count_is_suppressed_when_deadline_precedes_emission() { + let ready = Arc::new(AtomicBool::new(false)); + let polled = Arc::new(tokio::sync::Notify::new()); + let waker = Arc::new(Mutex::new(None)); + let state = Arc::new(Mutex::new(MockSinkState::default())); + let sink = ReadinessBarrierSink { + ready: Arc::clone(&ready), + polled: Arc::clone(&polled), + waker: Arc::clone(&waker), + state: Arc::clone(&state), + }; + let (data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + data_tx + .send(OutboundData::plain(WsMessage::Text( + RelayMessage::count("expiry-race", 1).into(), + ))) + .await + .expect("queue COUNT before expiry"); + drop(data_tx); + + let task = tokio::spawn(send_loop_inner(sink, data_rx, ctrl_rx, cancel.clone())); + polled.notified().await; + tokio::time::advance(Duration::from_millis(500)).await; + cancel.cancel(); + task.await.expect("send loop joins"); + + assert!(cancel.is_cancelled()); + let messages = &state.lock().expect("barrier sink poisoned").messages; + assert!( + messages + .iter() + .all(|message| !matches!(message, WsMessage::Text(_))), + "expired session must not emit its queued COUNT" + ); + } + #[tokio::test] async fn send_loop_batch_one_preserves_single_frame_flush_behavior() { let (data_tx, data_rx) = mpsc::channel(1); let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); data_tx - .send(WsMessage::Text("single".into())) + .send(OutboundData::plain(WsMessage::Text("single".into()))) .await .expect("queue data frame"); @@ -844,11 +1430,11 @@ mod tests { let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH); let (ctrl_tx, ctrl_rx) = mpsc::channel(1); data_tx - .send(WsMessage::Text("data-0".into())) + .send(OutboundData::plain(WsMessage::Text("data-0".into()))) .await .expect("queue data frame"); data_tx - .send(WsMessage::Text("data-1".into())) + .send(OutboundData::plain(WsMessage::Text("data-1".into()))) .await .expect("queue data frame"); ctrl_tx @@ -905,4 +1491,35 @@ mod tests { "Close is sent only after the reason frame is flushed" ); } + + #[tokio::test] + async fn protected_count_denial_is_visible_before_terminal_close() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (ctrl_tx, ctrl_rx) = mpsc::channel(1); + ctrl_tx + .send(WsMessage::Text( + RelayMessage::closed( + "deny-count", + "auth-required: protected authorization denied", + ) + .into(), + )) + .await + .expect("queue protected COUNT denial"); + + let cancel = CancellationToken::new(); + cancel.cancel(); + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.messages.len(), 2); + assert!(matches!( + &state.messages[0], + WsMessage::Text(text) + if text.as_str().contains("deny-count") + && text.as_str().contains("protected authorization denied") + )); + assert!(matches!(state.messages[1], WsMessage::Close(_))); + } } diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index ee4ba45155..971d2b3577 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -4,6 +4,7 @@ //! Nostr proof layer; corporate identity is deployment policy layered after a //! request proves control of a Nostr key. +use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,18 +20,24 @@ use jsonwebtoken::{ use nostr::{Event, EventBuilder, FromBech32, Kind, PublicKey, Tag, Timestamp}; use serde::Deserialize; use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; use thiserror::Error; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, warn}; +use buzz_auth::{ + AuthorizationClock, AuthorizationClockError, SharedAuthorizationClock, SystemAuthorizationClock, +}; use buzz_core::{kind::KIND_USER_TRUSTED_ASSERTION, CommunityId}; -use buzz_db::event::EventQuery; use buzz_db::identity_binding::{BindIdentityResult, SOURCE_DB_BINDING, SOURCE_JWT_NPUB}; +use buzz_pubsub::EventTopic; use crate::config::{CorporateIdentityAuthPrecedence, CorporateIdentityConfig}; use crate::state::AppState; const JWKS_CACHE_TTL: Duration = Duration::from_secs(300); +const JWKS_CACHE_MAX_AGE: Duration = Duration::from_secs(15 * 60); +const JWKS_REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(30); const JWKS_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); const JWKS_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const JWKS_MAX_RESPONSE_BYTES: usize = 1024 * 1024; @@ -38,15 +45,27 @@ 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); +const PUBLIC_PROJECTION_RECONCILIATION_INTERVAL: Duration = Duration::from_secs(1); +const PUBLIC_PROJECTION_STARTUP_LIMIT: usize = 4096; #[derive(Debug, Clone)] struct CachedJwks { set: JwkSet, - expires_at: Instant, + fetched_at: Instant, + fresh_until: Instant, + hard_expires_at: Instant, + refresh_after: Instant, +} + +fn record_jwks_cache_age(cached: &CachedJwks, now: Instant) { + metrics::gauge!("buzz_jwks_cache_age_seconds").set( + now.saturating_duration_since(cached.fetched_at) + .as_secs_f64(), + ); } /// Validated corporate identity claims used by Buzz. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct CorporateJwtClaims { /// Validated identity-provider issuer. pub issuer: String, @@ -62,24 +81,68 @@ pub struct CorporateJwtClaims { pub expires_at: u64, } -#[derive(Debug, Deserialize)] +impl fmt::Debug for CorporateJwtClaims { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> 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 +154,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 +201,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 +234,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 +263,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 +386,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 +396,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 +412,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 +441,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 +547,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 +608,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 +631,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 +697,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 +746,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 +804,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 +814,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", + } + } } -/// Extract a corporate identity JWT from the configured request header. +/// 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 + } +} + +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 +952,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 +973,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 +1007,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 +1062,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 +1095,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 +1180,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 +1255,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 +1301,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 +1343,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 +1373,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 +1397,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 +1634,14 @@ 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(relationship) = verify_unconditional_nip_oa_relationship(signer, auth_tag_json) + { + let owner_pubkey = relationship.owner_pubkey(); 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,16 +1657,86 @@ async fn verify_delegated_corporate_identity( } } -fn extract_unconditional_nip_oa_owner( +/// Exact verified identity and revision of one immutable NIP-OA relationship. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct VerifiedNipOaRelationship { + owner_pubkey: PublicKey, + relationship_id: uuid::Uuid, + relationship_revision: u64, +} + +impl VerifiedNipOaRelationship { + /// Verified owner that signed the relationship. + pub const fn owner_pubkey(self) -> PublicKey { + self.owner_pubkey + } + + /// Domain-separated identity of the exact verified signed relationship. + pub const fn relationship_id(self) -> uuid::Uuid { + self.relationship_id + } + + /// Monotonic revision of this immutable relationship issuance. + pub const fn relationship_revision(self) -> u64 { + self.relationship_revision + } +} + +impl fmt::Debug for VerifiedNipOaRelationship { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedNipOaRelationship") + .field("owner_pubkey", &"[redacted]") + .field("relationship_id", &"[redacted]") + .field("relationship_revision", &"[redacted]") + .finish() + } +} + +/// Verify and identify an unconditional NIP-OA relationship. +/// +/// The exact relationship ID is derived only after signature verification +/// from the canonical signed tag and authenticated delegate key. Each signed +/// immutable issuance starts at revision one; a different issuance receives a +/// different relationship ID rather than reusing an owner-wide selector. +pub fn verify_unconditional_nip_oa_relationship( signer: PublicKey, auth_tag_json: Option<&str>, -) -> Option { +) -> Option { let tag_json = auth_tag_json?; let tag: Vec = serde_json::from_str(tag_json).ok()?; if tag.len() != 4 || tag.get(2).and_then(Value::as_str) != Some("") { return None; } - buzz_sdk::nip_oa::verify_auth_tag(tag_json, &signer).ok() + let owner_pubkey = buzz_sdk::nip_oa::verify_auth_tag(tag_json, &signer).ok()?; + let canonical_tag = serde_json::to_vec(&tag).ok()?; + let mut hasher = Sha256::new(); + hasher.update(b"buzz:nip-oa:delegated-relationship:v1"); + hasher.update(signer.to_bytes()); + hasher.update((canonical_tag.len() as u64).to_be_bytes()); + hasher.update(canonical_tag); + let digest = hasher.finalize(); + let mut identity = [0_u8; 16]; + identity.copy_from_slice(&digest[..16]); + identity[6] = (identity[6] & 0x0f) | 0x80; + identity[8] = (identity[8] & 0x3f) | 0x80; + Some(VerifiedNipOaRelationship { + owner_pubkey, + relationship_id: uuid::Uuid::from_bytes(identity), + relationship_revision: 1, + }) +} + +/// 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 { + verify_unconditional_nip_oa_relationship(signer, auth_tag_json) + .map(VerifiedNipOaRelationship::owner_pubkey) } fn is_allowed_jwt_algorithm(algorithm: Algorithm) -> bool { @@ -1095,11 +1812,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 +1879,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 +1920,7 @@ pub fn service_from_config( config: &CorporateIdentityConfig, ) -> Option> { config - .require + .verifier_configured() .then(|| Arc::new(CorporateIdentityService::new(config.clone()))) } @@ -1200,23 +1935,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 +1965,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 +2003,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 +2014,206 @@ 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 o4_shared_contract_repairs_are_redacted_and_revision_exact() { + let sentinel_key = "private_raw_claim_key"; + let sentinel_value = "private_raw_claim_value"; + let raw_claims = RawJwtClaims { + claims: Map::from_iter([( + sentinel_key.to_string(), + Value::String(sentinel_value.to_string()), + )]), + }; + let debug = format!("{raw_claims:?}"); + let delegation_evidence = include_str!("../../buzz-auth/src/context/evidence.rs"); + let invalidation_contract = include_str!("../../buzz-db/src/authorization_invalidation.rs"); + + let mut missing = Vec::new(); + if debug.contains(sentinel_key) || debug.contains(sentinel_value) { + missing.push("raw-jwt-debug-redaction"); + } + if !(delegation_evidence.contains("DelegatedRelationshipId") + && delegation_evidence.contains("DelegatedRelationshipRevision") + && delegation_evidence.contains("relationship_id") + && delegation_evidence.contains("relationship_revision")) + { + missing.push("delegated-relationship-identity-and-revision"); + } + if !(invalidation_contract.contains("AuthorizationSessionTarget") + && invalidation_contract.contains("issuance_fence") + && invalidation_contract.contains("session-issuance-v2")) + { + missing.push("session-issuance-nonreuse-fence"); + } + + assert!( + missing.is_empty(), + "missing O4 shared-contract repairs: {missing:?}" + ); + + let session_id = Uuid::from_u128(0x801); + let first_session = buzz_db::authorization_invalidation::AuthorizationSessionTarget::new( + session_id, + Uuid::from_u128(0x802), + ) + .expect("first session issuance is valid"); + let second_session = buzz_db::authorization_invalidation::AuthorizationSessionTarget::new( + session_id, + Uuid::from_u128(0x803), + ) + .expect("second session issuance is valid"); + let first_session_fingerprint = + buzz_db::authorization_invalidation::AuthorizationSelector::session(first_session) + .fingerprint(); + let second_session_fingerprint = + buzz_db::authorization_invalidation::AuthorizationSelector::session(second_session) + .fingerprint(); + assert_ne!(first_session_fingerprint, second_session_fingerprint); + + let relationship_id = Uuid::from_u128(0x804); + let first_relationship = + buzz_db::authorization_invalidation::AuthorizationSelector::delegated_relationship( + relationship_id, + 1, + ) + .expect("first relationship revision is valid"); + let second_relationship = + buzz_db::authorization_invalidation::AuthorizationSelector::delegated_relationship( + relationship_id, + 2, + ) + .expect("second relationship revision is valid"); + assert_ne!( + first_relationship.fingerprint(), + second_relationship.fingerprint() + ); + } + + #[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 +2467,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 +2589,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 +2737,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 +2862,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 +2870,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 +2938,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 +3033,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 +3088,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 +3121,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 +3193,271 @@ 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, + 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(); + 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, + buzz_db::identity_lifecycle::LifecycleOperationId::issue(), + observational_subject.as_bytes(), + observational_subject.as_bytes(), + "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/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 1aa79aafea..530e286768 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -12,6 +12,9 @@ use std::sync::Arc; use axum::extract::ws::Message as WsMessage; +use buzz_auth::{ + AuthTransport, VerifiedDelegationOutput, VerifiedEvidenceAdapter, VerifiedNostrProof, +}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -42,6 +45,7 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { #[tracing::instrument(skip_all, fields(event_id, conn_id))] pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { let event_id_hex = event.id.to_hex(); + let verified_event = event.clone(); let (challenge, conn_id) = { let auth = conn.auth_state.read().await; match &*auth { @@ -91,6 +95,28 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + if state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(conn.tenant.community())) + == Some( + crate::authorization_runtime::finalization::AuthorizationMode::DenyProtected, + ) + { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "deny_protected" + ) + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected authorization unavailable", + )); + conn.cancel.cancel(); + return; + } + // Community ban gate (NIP-42 seam). Runs immediately after auth // verification succeeds and before the allowlist and relay-membership // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the @@ -124,7 +150,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -146,7 +172,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, owner = %owner.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "owner ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -166,7 +192,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: }; if let Some((metric_reason, deny_reason)) = denial { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); + warn!(conn_id = %conn_id, reason = deny_reason, "principal denied at ban seam"); metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -183,26 +209,61 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } + let identity_lane = crate::authorization_runtime::transport::legacy_identity_lane( + &state, + conn.tenant.community(), + ); let identity_proof = match crate::corporate_identity::verify_corporate_identity( &state, conn.tenant.community(), pubkey, - conn.corporate_identity_jwt.as_deref(), + conn.corporate_identity_assertion.as_ref(), auth_tag_json.as_deref(), ) .await { - Ok(proof) => proof, + Ok(proof) => Some(proof), Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + warn!(conn_id = %conn_id, error = ?e, "corporate identity denied"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + } + }; + + let verified_assertion = match identity_proof.as_ref() { + Some(proof) => { + match crate::corporate_identity::current_verified_assertion_for_proof( + &state, + proof, + conn.tenant.community(), + AuthTransport::RelayWebSocket, + ) { + Ok(assertion) => assertion.map(Arc::new), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "federated evidence sealing failed"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + *conn.auth_state.write().await = AuthState::Failed; + return; + } + } + } } + None => None, }; // Pubkey allowlist gate — only for pubkey-only auth. @@ -216,13 +277,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(v) => v, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "allowlist DB lookup failed, denying (fail-closed)"); false } }; if !allowed { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); + warn!(conn_id = %conn_id, "pubkey not in allowlist"); metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -246,7 +307,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(owner) => owner, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?e, "not a relay member"); + warn!(conn_id = %conn_id, error = ?e, "not a relay member"); metrics::counter!("buzz_auth_failures_total", "reason" => "not_relay_member") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -259,30 +320,40 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - let identity_decision = match crate::corporate_identity::finalize_corporate_identity( - &state, - conn.tenant.community(), - pubkey, - identity_proof, - ) - .await + let identity_decision = if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy { - Ok(decision) => decision, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity finalization denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + if let Some(identity_proof) = identity_proof.clone() { + match crate::corporate_identity::finalize_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => Some(decision), + Err(e) => { + warn!(conn_id = %conn_id, error = ?e, "corporate identity finalization denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + } + } else { + None } + } else { + None }; - if let crate::corporate_identity::CorporateIdentityDecision::Delegated { + if let Some(crate::corporate_identity::CorporateIdentityDecision::Delegated { owner_pubkey, .. - } = &identity_decision + }) = &identity_decision { auth_ctx.agent_owner_pubkey = Some(*owner_pubkey); } @@ -305,37 +376,102 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship. if let Some(owner) = nip_oa_owner { - if crate::api::relay_members::materialize_nip_oa_owner( - &state, - &conn.tenant, - &pubkey, - &owner, - ) - .await - { + let owner_is_current = identity_lane + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + || crate::api::relay_members::materialize_nip_oa_owner( + &state, + &conn.tenant, + &pubkey, + &owner, + ) + .await; + if owner_is_current { auth_ctx.agent_owner_pubkey = Some(owner); } else { warn!( conn_id = %conn_id, - agent = %pubkey.to_hex(), - nip_oa_owner = %owner.to_hex(), "NIP-OA owner could not be materialized" ); } } - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + info!(conn_id = %conn_id, "NIP-42 auth successful"); + let transport_delegation = + crate::corporate_identity::verify_unconditional_nip_oa_relationship( + pubkey, + auth_tag_json.as_deref(), + ) + .map(|relationship| { + VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, + ) + }); + let verified_proof: Arc = match VerifiedEvidenceAdapter::new() + .verify_nip42( + conn.tenant.community(), + AuthTransport::RelayWebSocket, + &verified_event, + &challenge, + &relay_url, + transport_delegation, + ) { + Ok(proof) => Arc::new(proof), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "sealed NIP-42 evidence creation failed"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + }; *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - conn.tenant.community(), - pubkey, - identity_decision, - conn.cancel.clone(), + state.conn_manager.set_authenticated_authority( + conn_id, + Arc::clone(&verified_proof), + verified_assertion.clone(), ); + if let (Some(runtime), Some(assertion)) = + (state.client_status_runtime().cloned(), verified_assertion) + { + if let Err(error) = runtime + .present_after_auth( + Arc::clone(&state), + verified_proof, + assertion, + conn_id, + conn.cancel.clone(), + ) + .await + { + // Presentation failure never widens or narrows access. The + // client receives no current indicator and clears any old + // status on its existing freshness/disconnect boundary. + metrics::counter!("buzz_client_status_degradation_total").increment(1); + warn!( + conn_id = %conn_id, + reason = "client_status_unavailable", + "client binding status withheld" + ); + tracing::debug!(error = %error, "client binding status detail"); + } + } + if let Some(identity_decision) = identity_decision { + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + conn.tenant.community(), + pubkey, + identity_decision, + conn.cancel.clone(), + ); + } conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { @@ -356,6 +492,48 @@ mod tests { use super::extract_auth_tag_json; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn observational_auth_cannot_enter_mutating_identity_lane() { + use crate::authorization_runtime::{ + finalization::AuthorizationMode, + transport::{legacy_identity_lane_for_mode, LegacyIdentityLane}, + }; + + let mut binding_writes = 0; + let mut membership_writes = 0; + let mut public_projection_writes = 0; + for mode in [ + AuthorizationMode::Shadow, + AuthorizationMode::VerifyOnly, + AuthorizationMode::Enforce, + ] { + if legacy_identity_lane_for_mode(Some(mode)) == LegacyIdentityLane::Legacy { + binding_writes += 1; + membership_writes += 1; + public_projection_writes += 1; + } + } + assert_eq!(binding_writes, 0); + assert_eq!(membership_writes, 0); + assert_eq!(public_projection_writes, 0); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Off)), + LegacyIdentityLane::Legacy + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Shadow)), + LegacyIdentityLane::ObserveOnly + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::VerifyOnly)), + LegacyIdentityLane::ObserveOnly + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Enforce)), + LegacyIdentityLane::ProtectedEnforce + ); + } + /// Build a signed NIP-98 (kind 27235) event carrying the given tags. The /// `auth` tag lives inside the signed event exactly as the git and /// WebSocket auth paths receive it. diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807..0fcbca473a 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -38,30 +38,42 @@ pub async fn handle_command( state: &Arc, event: Event, auth: IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, ) -> Result { // Ensure the authenticated user exists in the users table (foreign key requirement). // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); - match state - .db - .ensure_user(tenant.community(), &pubkey_bytes) - .await - { - Ok(true) => { - metrics::counter!( - "buzz_users_created_total", - "community" => tenant.host().to_owned() - ) - .increment(1); - } - Ok(false) => {} - Err(e) => { - tracing::warn!("command_executor: ensure_user failed: {e}"); + if !protected.is_enforcing() { + match state + .db + .ensure_user(tenant.community(), &pubkey_bytes) + .await + { + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => crate::metrics::community_label(tenant.community()) + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("command_executor: ensure_user failed: {e}"); + } } } let kind = event.kind.as_u16() as u32; match kind { + KIND_DM_OPEN if protected.is_enforcing() => { + handle_dm_open_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_ADD_MEMBER if protected.is_enforcing() => { + handle_dm_add_member_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_HIDE if protected.is_enforcing() => { + handle_dm_hide_enforced(tenant, state, &event, &auth, protected).await + } KIND_DM_OPEN => handle_dm_open(tenant, state, &event, &auth).await, KIND_DM_ADD_MEMBER => handle_dm_add_member(tenant, state, &event, &auth).await, KIND_DM_HIDE => handle_dm_hide(tenant, state, &event, &auth).await, @@ -307,6 +319,237 @@ fn compute_definition_hash(json_str: &str) -> Vec { Sha256::digest(json_str.as_bytes()).to_vec() } +async fn begin_protected_command( + state: &AppState, + tenant: &TenantContext, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.command.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "event.command.v1", request.finalize().into()) + .map_err(|_| IngestError::AuthFailed("restricted: protected authorization denied".into()))? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}"))) +} + +fn replayed_command_result(event: &Event, payload: Vec) -> Result { + let message = String::from_utf8(payload) + .map_err(|_| IngestError::Internal("error: protected command receipt is invalid".into()))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) +} + +async fn persist_command_event_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + event: &Event, +) -> Result<(), IngestError> { + buzz_db::event::insert_event_with_thread_metadata_tx( + transaction, + tenant.community(), + event, + extract_channel_id(event), + None, + ) + .await + .map(|_| ()) + .map_err(|error| IngestError::Internal(format!("error: persist command: {error}"))) +} + +async fn handle_dm_open_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let tags = extract_p_tags(event); + if tags.is_empty() || tags.len() > 8 { + return Err(IngestError::Rejected( + "invalid: DM requires 1-8 other participants".into(), + )); + } + let mut participants = vec![actor.clone()]; + for tag in tags { + let pubkey = decode_pubkey(&tag)?; + if !participants.contains(&pubkey) { + participants.push(pubkey); + } + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in &participants { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let refs = participants.iter().map(Vec::as_slice).collect::>(); + let (channel, created) = + buzz_db::dm::open_dm_tx(operation.transaction(), tenant.community(), &refs, &actor) + .await + .map_err(|error| IngestError::Internal(format!("error: open DM: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({ + "channel_id": channel.id.to_string(), + "created": created, + }) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in &participants { + state.invalidate_membership(tenant, channel.id, participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_add_member_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + let additions = extract_p_tags(event) + .into_iter() + .map(|value| decode_pubkey(&value)) + .collect::, _>>()?; + if additions.is_empty() { + return Err(IngestError::Rejected( + "invalid: at least one participant is required".into(), + )); + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in additions.iter().chain(std::iter::once(&actor)) { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let (channel, _created, participants) = buzz_db::dm::expand_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &additions, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({"channel_id": channel.id.to_string()}) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in participants { + state.invalidate_membership(tenant, channel.id, &participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_hide_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + persist_command_event_tx(operation.transaction(), tenant, event).await?; + buzz_db::dm::hide_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = "{}".to_string(); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + async fn handle_dm_open( tenant: &TenantContext, state: &Arc, @@ -374,7 +617,7 @@ async fn handle_dm_open( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); @@ -535,7 +778,7 @@ async fn handle_dm_add_member( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 3eeab5e807..7b4cce662e 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -50,6 +50,40 @@ pub async fn handle_count( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn.conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state + .conn_manager + .federated_assertion_for_conn(conn.conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_count", + conn.conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(error = %error, "protected COUNT authorization denied"); + conn.send_terminal(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + // P-gated kinds (gift wraps, member notifications, observer frames) require // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. let authed_pubkey_hex = hex::encode(&pubkey_bytes); @@ -83,7 +117,10 @@ pub async fn handle_count( Ok(ids) => ids, Err(e) => { warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } }; @@ -98,6 +135,7 @@ pub async fn handle_count( // For each filter, count matching events with channel access enforcement. let mut total: u64 = 0; + let mut release_channels = std::collections::BTreeSet::new(); for filter in &filters { // Determine if this filter can match author-only kinds — if so, the // fast-path count_events() cannot be used because it doesn't do @@ -134,7 +172,10 @@ pub async fn handle_count( Ok(member) => Some(member), Err(e) => { warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -149,6 +190,7 @@ pub async fn handle_count( ) { continue; // Skip filters targeting inaccessible channels. } + release_channels.insert(ch_id); // Channel is accessible — count with pushability check. let mut query = super::req::build_event_query_from_filter( filter, @@ -176,7 +218,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -192,10 +237,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -210,7 +258,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -222,6 +273,7 @@ pub async fn handle_count( // If the filter has generic tags beyond what SQL can push down // (#h, #p single, #d single, #e), we must fall back to // query + post-filter to avoid overcounting. + release_channels.extend(accessible_channels.iter().copied()); let mut query = super::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -250,7 +302,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -265,10 +320,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -283,12 +341,24 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } } } } - conn.send(RelayMessage::count(&sub_id, total)); + let release = crate::connection::queued_channel_set_read_authority( + state.db.clone(), + conn.tenant.community(), + release_channels.into_iter().collect(), + pubkey_bytes, + Some(protected), + ); + if !conn.send_guarded(RelayMessage::count(&sub_id, total), release) { + conn.cancel.cancel(); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 288129fd62..873f0b21ad 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -31,6 +31,35 @@ fn reject(reason: &'static str) { reject_with_transport("ws", reason); } +fn seal_ephemeral_authority( + state: &AppState, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + event: &Event, +) -> Result, crate::authorization_runtime::ephemeral::EphemeralAuthorityError> { + authority + .is_enforcing() + .then(|| crate::authorization_runtime::ephemeral::seal(state, authority, event)) + .transpose() +} + +async fn publish_ephemeral_event( + state: &AppState, + tenant: &TenantContext, + topic: EventTopic, + event: &Event, + authority: Option<&str>, +) -> Result { + match authority { + Some(authority) => { + state + .pubsub + .publish_event_with_authority(tenant, topic, event, authority) + .await + } + None => state.pubsub.publish_event(tenant, topic, event).await, + } +} + /// Bound the `kind` label to prevent cardinality explosion from arbitrary Nostr kinds. pub(crate) fn bounded_kind_label(kind: u32) -> String { match kind { @@ -73,23 +102,70 @@ where frames } +/// A live fan-out target that retains exact authority until socket drain. +pub struct ProtectedFanoutRecipient { + conn_id: crate::subscription::ConnId, + sub_id: crate::subscription::SubId, + authority: Option>, +} + +impl std::fmt::Debug for ProtectedFanoutRecipient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProtectedFanoutRecipient") + .field("conn_id", &self.conn_id) + .field("sub_id", &self.sub_id) + .field("guarded", &self.authority.is_some()) + .finish() + } +} + +impl PartialEq<(crate::subscription::ConnId, crate::subscription::SubId)> + for ProtectedFanoutRecipient +{ + fn eq(&self, other: &(crate::subscription::ConnId, crate::subscription::SubId)) -> bool { + self.conn_id == other.0 && self.sub_id == other.1 + } +} + fn send_fanout_frames<'a, I>( state: &AppState, recipients: I, frames: &HashMap<&'a str, Arc>, + sender_authority: Option<&Arc>, ) -> u32 where - I: IntoIterator, + I: IntoIterator, { let mut drop_count = 0u32; - for (conn_id, sub_id) in recipients { + for recipient in recipients { let frame = frames - .get(sub_id) + .get(recipient.sub_id.as_str()) .expect("fan-out frame cache covers every recipient subscription id"); - if !state - .conn_manager - .send_to_text_bytes(conn_id, Arc::clone(frame)) - { + let sent = match (&recipient.authority, sender_authority) { + (Some(recipient_authority), Some(sender_authority)) => { + state.conn_manager.send_to_text_bytes_guarded_pair( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + Arc::clone(recipient_authority), + ) + } + (Some(authority), None) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(authority), + ), + (None, Some(sender_authority)) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + ), + (None, None) => state + .conn_manager + .send_to_text_bytes(recipient.conn_id, Arc::clone(frame)), + }; + if !sent { drop_count += 1; } } @@ -118,7 +194,7 @@ pub async fn filter_fanout_by_access( stored_event: &StoredEvent, matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, threaded: Option<&crate::state::ThreadedChannelVisibility>, -) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { +) -> Vec { // First enforce the receiver-side tenant label. Subscription indexes are // community-scoped, but stale/injected matches and future fan-out helpers // must still fail closed at the send chokepoint: a connection bound to @@ -175,7 +251,7 @@ pub async fn filter_fanout_by_access( }; let Some(channel_id) = stored_event.channel_id else { - return matches; + return filter_fanout_by_protected_authorization(state, community_id, None, matches).await; }; // Fence 3 (§4.8 phase-2): the threaded value is used only when it was // resolved under exactly this (community_id, channel_id); anything else @@ -192,7 +268,15 @@ pub async fn filter_fanout_by_access( } }; match visibility { - Ok(v) if v != "private" => return matches, + Ok(v) if v != "private" => { + return filter_fanout_by_protected_authorization( + state, + community_id, + Some(channel_id), + matches, + ) + .await; + } Ok(_) => {} Err(e) => { // Fail closed: if we cannot determine visibility, do not leak a @@ -218,6 +302,89 @@ pub async fn filter_fanout_by_access( } } } + filter_fanout_by_protected_authorization(state, community_id, Some(channel_id), allowed).await +} + +async fn filter_fanout_by_protected_authorization( + state: &AppState, + community_id: CommunityId, + channel_id: Option, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, +) -> Vec { + if state.protected_transport().is_none() { + return matches + .into_iter() + .map(|(conn_id, sub_id)| { + let authority = match channel_id { + Some(channel_id) => state.conn_manager.pubkey_for_conn(conn_id).map(|actor| { + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + None, + ) + }), + None => None, + }; + ProtectedFanoutRecipient { + conn_id, + sub_id, + authority, + } + }) + .collect(); + } + let mut allowed = Vec::with_capacity(matches.len()); + for (conn_id, sub_id) in matches { + let Some(proof) = state.conn_manager.authority_for_conn(conn_id) else { + continue; + }; + if proof.authorization_domain() != community_id { + state.conn_manager.cancel_connection(conn_id); + continue; + } + let Some(cancellation) = state.conn_manager.cancellation_for_conn(conn_id) else { + continue; + }; + match crate::authorization_runtime::transport::authorize_session_if_configured( + state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_fanout", + conn_id, + cancellation, + ) + .await + { + Ok(authority) if authority.revalidate().is_ok() => { + let authority = Arc::new(authority); + let release = match channel_id { + Some(channel_id) => { + let Some(actor) = state.conn_manager.pubkey_for_conn(conn_id) else { + continue; + }; + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + Some(authority), + ) + } + None => crate::connection::queued_local_authority(authority), + }; + allowed.push(ProtectedFanoutRecipient { + conn_id, + sub_id, + authority: Some(release), + }); + } + Ok(_) | Err(_) => state.conn_manager.cancel_connection(conn_id), + } + } allowed } @@ -243,6 +410,17 @@ pub(crate) async fn fan_out_event_to_local_subscribers( community_id: CommunityId, stored: &StoredEvent, ) { + fan_out_event_to_local_subscribers_with_authority(state, community_id, stored, None).await; +} + +async fn fan_out_event_to_local_subscribers_with_authority( + state: &AppState, + community_id: CommunityId, + stored: &StoredEvent, + sender_authority: Option<&Arc>, +) { + let sender_authority = sender_authority + .map(|authority| crate::connection::queued_local_authority(Arc::clone(authority))); let matches = state.sub_registry.fan_out_scoped(community_id, stored); let matches = filter_fanout_by_access(state, community_id, stored, matches, None).await; metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); @@ -258,16 +436,10 @@ pub(crate) async fn fan_out_event_to_local_subscribers( } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -280,16 +452,50 @@ pub(crate) async fn fan_out_event_to_local_subscribers( /// Fan out one event received from Redis pub/sub to this relay's local subscribers. #[tracing::instrument(skip_all)] pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pubsub::ChannelEvent) { + let buzz_pubsub::ChannelEvent { + community_id, + topic, + event, + authority, + } = channel_event; // The Redis topic carries the tenant-local routing scope explicitly: // `Channel(id)` for a per-channel event, `Global` for a channel-less one. // Convert back to the `Option` channel id `fan_out()` indexes on — // `Global` selects the global subscriber index. - let channel_id = match channel_event.topic { + let channel_id = match topic { buzz_pubsub::EventTopic::Channel(id) => Some(id), buzz_pubsub::EventTopic::Global => None, }; - let community_id = channel_event.community_id; - let stored = StoredEvent::new(channel_event.event, channel_id); + let protected_ephemeral = + is_ephemeral(event_kind_u32(&event)) || event_kind_u32(&event) == KIND_AGENT_OBSERVER_FRAME; + let sender_authority = match authority { + Some(authority) if protected_ephemeral => { + match crate::authorization_runtime::ephemeral::verify( + state, + community_id, + &event, + &authority, + ) + .await + { + Ok(authority) => Some(authority), + Err(error) => { + warn!(%error, "multi-node ephemeral sender authority denied"); + return; + } + } + } + Some(_) => { + warn!("multi-node persistent event carried unexpected sender authority"); + return; + } + None if protected_ephemeral && state.is_protected_enforcing(community_id) => { + warn!("multi-node Enforce ephemeral event omitted sender authority"); + return; + } + None => None, + }; + let stored = StoredEvent::new(event, channel_id); // Skip events that were already fanned out in-process (local echo). The // dedup key is `(community_id, event_id)` — a same-id event arriving for a @@ -318,16 +524,10 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -355,15 +555,17 @@ pub(crate) async fn dispatch_persistent_event( threaded_visibility: Option, ) -> usize { let event_id_hex = stored_event.event.id.to_hex(); - enqueue_event_created_audit( - tenant, - state, - stored_event, - kind_u32, - actor_pubkey_hex, - &event_id_hex, - ) - .await; + if legacy_audit_delivery_allowed(state, tenant.community()) { + enqueue_event_created_audit( + tenant, + state, + stored_event, + kind_u32, + actor_pubkey_hex, + &event_id_hex, + ) + .await; + } let tenant = tenant.clone(); let state = Arc::clone(state); @@ -476,21 +678,20 @@ async fn dispatch_persistent_event_inner( // frames only after applying it to the already access-filtered recipient set. let recipients: Vec<_> = matches .iter() - .filter_map(|(target_conn_id, sub_id)| { - if let Some(ref owner_hex) = private_event_owner { - let is_owner = state + .filter(|recipient| { + private_event_owner.as_ref().is_none_or(|owner_hex| { + state .conn_manager - .pubkey_for(*target_conn_id) - .is_some_and(|pk| hex::encode(pk) == *owner_hex); - if !is_owner { - return None; - } - } - Some((*target_conn_id, sub_id.as_str())) + .pubkey_for(recipient.conn_id) + .is_some_and(|pk| hex::encode(pk) == *owner_hex) + }) }) .collect(); - let frames = fanout_frame_cache(recipients.iter().map(|(_, sub_id)| *sub_id), &event_json); - let drop_count = send_fanout_frames(state, recipients, &frames); + let frames = fanout_frame_cache( + recipients.iter().map(|recipient| recipient.sub_id.as_str()), + &event_json, + ); + let drop_count = send_fanout_frames(state, recipients, &frames, None); if drop_count > 0 { tracing::warn!( event_id = %event_id_hex, @@ -505,7 +706,7 @@ async fn dispatch_persistent_event_inner( // out-of-band index to feed. The old Typesense `index_event` worker and its // `search_index_tx` mpsc are gone with the Typesense backend. - if enqueue_audit { + if enqueue_audit && legacy_audit_delivery_allowed(state, tenant.community()) { enqueue_event_created_audit( tenant, state, @@ -525,7 +726,15 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !buzz_core::kind::is_workflow_execution_kind(kind_u32) + let workflow_effect_allowed = crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())), + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .is_ok(); + if workflow_effect_allowed + && !buzz_core::kind::is_workflow_execution_kind(kind_u32) && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP @@ -533,24 +742,24 @@ async fn dispatch_persistent_event_inner( let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); let trigger_kind = kind_u32.to_string(); - let workflow_community_host = tenant.host().to_owned(); // The event was stored under `tenant.community()`; `StoredEvent` does // not carry the community, so pass it explicitly. The same channel UUID // can exist in another community — scoping the workflow lookup to this // community keeps a colliding channel id in B from triggering A's // workflows. let workflow_community = tenant.community(); + let workflow_community_label = crate::metrics::community_label(workflow_community); tokio::spawn(async move { if let Err(e) = workflow_engine .on_event(workflow_community, &workflow_event) .await { - tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); + tracing::error!("Workflow trigger failed: {e}"); } else { metrics::counter!( "buzz_workflow_runs_total", "trigger" => trigger_kind, - "community" => workflow_community_host + "community" => workflow_community_label ) .increment(1); } @@ -560,6 +769,16 @@ async fn dispatch_persistent_event_inner( matches.len() } +fn legacy_audit_delivery_allowed(state: &AppState, community_id: CommunityId) -> bool { + crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_ok() +} + async fn enqueue_event_created_audit( tenant: &TenantContext, state: &Arc, @@ -622,22 +841,20 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc kind_str).increment(1); - // Per-community volume counter: community-only, no kind tag. - // Use this for per-community throughput graphs; the fleet counter above - // for per-kind breakdowns. metrics::counter!( "buzz_community_events_received_total", - "community" => conn.tenant.host().to_owned() + "community" => crate::metrics::community_label(conn.tenant.community()) ) .increment(1); - let (conn_id, pubkey_bytes, auth_pubkey, scopes, channel_ids) = { + let (conn_id, pubkey_bytes, auth_pubkey, owner_pubkey, scopes, channel_ids) = { let auth = conn.auth_state.read().await; match &*auth { AuthState::Authenticated(ctx) => ( conn.conn_id, ctx.pubkey.to_bytes().to_vec(), ctx.pubkey, + ctx.agent_owner_pubkey, ctx.scopes.clone(), ctx.channel_ids.clone(), ), @@ -677,6 +894,39 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + Arc::clone(proof), + state.conn_manager.federated_assertion_for_conn(conn_id), + crate::protected_surface::event_ingest_capability(kind_u32), + uuid::Uuid::new_v4(), + "ws_event", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = match protected_result { + Ok(authority) => Arc::new(authority), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected EVENT authorization denied"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }; if kind_u32 == KIND_AGENT_OBSERVER_FRAME { if !scopes.is_empty() && !scopes.contains(&buzz_auth::Scope::MessagesWrite) { reject("scope"); @@ -687,7 +937,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, state: Arc, state: Arc, auth_pubkey: nostr::PublicKey, conn: Arc, state: Arc, + authority: Arc, ) { + let conn_id = conn.conn_id; let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -790,6 +1060,14 @@ async fn handle_ephemeral_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "ephemeral sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Special handling for presence events (kind:20001). if event_kind_u32(&event) == KIND_PRESENCE_UPDATE { @@ -810,16 +1088,49 @@ async fn handle_ephemeral_event( raw }; - if status == "offline" { - let _ = state + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let stored_status = match redis_authority.as_ref() { + Some(token) => match crate::authorization_runtime::ephemeral::encode_presence( + status.clone(), + event.id.to_bytes(), + token.clone(), + ) { + Ok(value) => value, + Err(_) => { + conn.cancel.cancel(); + return; + } + }, + None => status.clone(), + }; + let presence_result = if status == "offline" { + state .pubsub .clear_presence(&conn.tenant, &auth_pubkey) - .await; + .await } else { + state + .pubsub + .set_presence(&conn.tenant, &auth_pubkey, &stored_status) + .await + }; + if authority.revalidate().is_err() { + // Cleanup is opportunistic. Protected values retain their sealed + // authority and are revalidated before every read or emission, so + // a failed DEL cannot make stale presence visible. let _ = state .pubsub - .set_presence(&conn.tenant, &auth_pubkey, &status) + .clear_presence(&conn.tenant, &auth_pubkey) .await; + conn.cancel.cancel(); + return; + } + if presence_result.is_err() && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Presence is a channel-less ephemeral event. After updating Redis @@ -841,20 +1152,78 @@ async fn handle_ephemeral_event( conn.send(RelayMessage::ok(event_id_hex, false, &msg)); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + + // In Enforce, retain database locks on the channel and the actor's + // active membership through the authoritative Redis publication. A + // concurrent removal or open-to-private transition therefore orders + // entirely before this publication (which then denies) or after it. + // Off/Shadow/VerifyOnly keep the legacy preflight behavior. + let channel_authority_guard = if authority.is_enforcing() { + let mut transaction = match state.db.begin_transaction().await { + Ok(transaction) => transaction, + Err(error) => { + warn!(%error, %ch_id, "ephemeral channel authority transaction failed"); + conn.cancel.cancel(); + return; + } + }; + if let Err(error) = buzz_db::channel::require_channel_write_authority_tx( + &mut transaction, + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: channel authority changed before publication", + )); + warn!(%error, %ch_id, "ephemeral channel authority denied"); + return; + } + Some(transaction) + } else { + None + }; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Channel(ch_id), &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Channel(ch_id), + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + drop(channel_authority_guard); + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers, through the guarded send path @@ -862,7 +1231,21 @@ async fn handle_ephemeral_event( // receive this private-channel ephemeral event. // Pass the channel_id so fan_out() uses the channel-kind index. let stored_event = StoredEvent::new(event.clone(), Some(ch_id)); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } else { // Channel-less ephemeral events (e.g., NIP-AB pairing kind:24134). // @@ -872,17 +1255,39 @@ async fn handle_ephemeral_event( // The nil UUID is ONLY a Redis routing key — it never reaches the DB. // On the receiving end (main.rs subscriber loop), `is_nil()` is checked // and converted back to `None` so `fan_out()` uses the global index. + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers through the guarded send path. @@ -890,9 +1295,27 @@ async fn handle_ephemeral_event( // filter_fanout_by_access no-ops for channel-less events except the // author-only-kind gate. let stored_event = StoredEvent::new(event.clone(), None); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -946,6 +1369,7 @@ async fn handle_agent_observer_event( event_id_hex: &str, conn: Arc, state: Arc, + authority: Arc, ) { let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -968,6 +1392,14 @@ async fn handle_agent_observer_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "observer sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Freshness check: reject observer frames with stale/future timestamps let now = chrono::Utc::now().timestamp(); @@ -1050,6 +1482,10 @@ async fn handle_agent_observer_event( )); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } // Rate limit telemetry frames only (100/sec per agent). // Control frames (owner → agent) bypass the limiter — they are rare and must not @@ -1066,27 +1502,60 @@ async fn handle_agent_observer_event( } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Agent observer publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } let stored_event = StoredEvent::new(event.clone(), None); debug!( - event_id = %event_id_hex, - agent = %route.agent.to_hex(), - owner = %route.owner.to_hex(), direction = ?route.direction, "Agent observer fan-out" ); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -1386,7 +1855,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - corporate_identity_jwt: None, + corporate_identity_assertion: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), @@ -1410,11 +1879,12 @@ mod tests { &event.id.to_hex(), conn, state, + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy), ) .await; let axum::extract::ws::Message::Text(text) = - send_rx.try_recv().expect("observer rejection sent") + send_rx.try_recv().expect("observer rejection sent").message else { panic!("expected text relay message"); }; @@ -1452,7 +1922,7 @@ mod tests { sub_id: &str, filter: Filter, pubkey: Option>, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); @@ -1478,7 +1948,7 @@ mod tests { fn register_presence_sub( state: &AppState, sub_id: &str, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1491,7 +1961,7 @@ mod tests { state: &AppState, sub_id: &str, target: &Keys, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1540,11 +2010,13 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; - let delivered = event_from_ws_message(rx.try_recv().expect("presence delivered")); + let delivered = + event_from_ws_message(rx.try_recv().expect("presence delivered").message); assert_eq!(delivered.id, event_id); assert!(rx.try_recv().is_err(), "presence is delivered once"); } @@ -1563,6 +2035,7 @@ mod tests { community_id: community, topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1605,13 +2078,15 @@ mod tests { community_id: community_b, topic: EventTopic::Global, event, + authority: None, }, ) .await; let delivered = event_from_ws_message( rx.try_recv() - .expect("B's same-id event must be delivered — A's local mark is B-irrelevant"), + .expect("B's same-id event must be delivered — A's local mark is B-irrelevant") + .message, ); assert_eq!(delivered.id, event_id); } @@ -1634,6 +2109,7 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1641,7 +2117,8 @@ mod tests { let delivered = event_from_ws_message( target_rx .try_recv() - .expect("target receives membership notification"), + .expect("target receives membership notification") + .message, ); assert_eq!(delivered.id, event_id); assert!( @@ -1729,7 +2206,7 @@ mod tests { .await .expect("presence reached second relay") .expect("receiver connection still open"); - let delivered = event_from_ws_message(delivered); + let delivered = event_from_ws_message(delivered.message); assert_eq!(delivered.id, event_id); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), receiver_rx.recv()) diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483f..113e9fe02c 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -138,6 +138,61 @@ pub async fn handle_identity_archive_event( Ok(()) } +/// Validate and apply an identity archive request inside the caller's protected +/// authorization transaction. The request event is persisted by the caller in +/// that same transaction; relay-signed deltas remain unavailable background +/// effects in Enforce. +pub async fn handle_identity_archive_event_tx( + tenant: &TenantContext, + state: &Arc, + event: &Event, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + let kind = event.kind.as_u16() as u32; + let actor_hex = event.pubkey.to_hex(); + if kind != KIND_IA_ARCHIVE_REQUEST && kind != KIND_IA_UNARCHIVE_REQUEST { + return Err(format!("unexpected identity archive kind: {kind}")); + } + enforce_freshness(event)?; + require_single_protected_tag(event)?; + let target_hex = extract_single_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + let replaced_by = extract_optional_replaced_by(event, &target_hex)?; + if kind == KIND_IA_UNARCHIVE_REQUEST && replaced_by.is_some() { + return Err("replaced-by is not valid on unarchive requests".into()); + } + let reason = extract_tag_value(event, "reason"); + let consent_path = determine_consent_path_tx( + tenant.community(), + state, + event, + &target_hex, + &actor_hex, + transaction, + ) + .await?; + let request_event_id = event.id.to_hex(); + if kind == KIND_IA_ARCHIVE_REQUEST { + buzz_db::archived_identities::archive_tx( + transaction, + tenant.community(), + &target_hex, + consent_path.as_str(), + &actor_hex, + reason.as_deref(), + replaced_by.as_deref(), + &request_event_id, + ) + .await + .map_err(|error| format!("database error: {error}")) + } else { + buzz_db::archived_identities::unarchive_tx(transaction, tenant.community(), &target_hex) + .await + .map_err(|error| format!("database error: {error}")) + } +} + fn enforce_freshness(event: &Event) -> Result<(), String> { let event_ts = event.created_at.as_secs() as i64; let now = std::time::SystemTime::now() @@ -250,6 +305,40 @@ async fn determine_consent_path( Ok(ConsentPath::Owner) } +async fn determine_consent_path_tx( + community_id: CommunityId, + state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + if actor_hex == target_hex { + return Ok(ConsentPath::SelfSigned); + } + let actor_member = + buzz_db::relay_members::get_relay_member_tx(transaction, community_id, actor_hex) + .await + .map_err(|error| format!("database error: {error}"))?; + let actor_role = actor_member + .as_ref() + .map(|member| member.role.as_str()) + .unwrap_or(""); + if actor_role == "owner" || actor_role == "admin" { + return Ok(ConsentPath::Admin); + } + verify_owner_consent_tx( + community_id, + state, + event, + target_hex, + actor_hex, + transaction, + ) + .await?; + Ok(ConsentPath::Owner) +} + async fn verify_owner_consent( community_id: CommunityId, state: &Arc, @@ -297,6 +386,76 @@ async fn verify_owner_consent( Ok(()) } +async fn verify_owner_consent_tx( + community_id: CommunityId, + _state: &Arc, + event: &Event, + target_hex: &str, + actor_hex: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), String> { + let request_auth = extract_single_auth_tag_json(event)?; + let request_owner = verify_auth_tag_owner(&request_auth, target_hex) + .map_err(|error| format!("invalid request auth tag: {error}"))?; + if request_owner != actor_hex { + return Err("request auth owner must equal request signer".into()); + } + enforce_request_auth_time_bounds(&request_auth, event.created_at.as_secs())?; + + let target_pubkey = PublicKey::from_hex(target_hex) + .map_err(|error| format!("invalid target pubkey: {error}"))?; + let target_author = target_pubkey.to_bytes().to_vec(); + + // The user-row share lock is also taken by the Enforce profile projection. + // It serializes archive consent against a concurrent kind:0 replacement so + // the profile read below remains the consent state through commit. + let target_exists = sqlx::query_scalar::<_, i32>( + "SELECT 1 FROM users \ + WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(&target_author) + .fetch_optional(&mut **transaction) + .await + .map_err(|error| format!("database error: {error}"))? + .is_some(); + if !target_exists { + return Err("target has no live user profile".into()); + } + + let profile = buzz_db::event::query_events_tx( + transaction, + &EventQuery { + kinds: Some(vec![KIND_PROFILE as i32]), + authors: Some(vec![target_author]), + limit: Some(1), + global_only: true, + ..EventQuery::for_community(community_id) + }, + ) + .await + .map_err(|error| format!("database error: {error}"))? + .into_iter() + .next() + .ok_or_else(|| "target has no live kind:0 profile".to_string())?; + if !buzz_db::event::lock_live_event_tx(transaction, community_id, profile.event.id.as_bytes()) + .await + .map_err(|error| format!("database error: {error}"))? + { + return Err("live kind:0 changed during authorization".into()); + } + if profile.event.pubkey.to_hex() != target_hex { + return Err("live kind:0 author did not match target".into()); + } + let live_auth = extract_single_auth_tag_json(&profile.event)?; + let live_owner = verify_auth_tag_owner(&live_auth, target_hex) + .map_err(|error| format!("invalid live kind:0 auth tag: {error}"))?; + if live_owner != actor_hex { + return Err("live kind:0 no longer attests to request signer".into()); + } + Ok(()) +} + fn extract_single_auth_tag_json(event: &Event) -> Result { let mut found: Option> = None; for tag in event.tags.iter() { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..a8fa49ddd5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -24,21 +24,23 @@ use buzz_core::kind::{ KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_CREATE_INVITE, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -65,6 +67,12 @@ pub enum IngestAuth { Nip42 { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, + /// Verified delegated owner, when present. + owner_pubkey: Option, + /// Sealed NIP-42 proof retained from connection authentication. + verified_proof: Option>, + /// Current direct federated evidence retained from authentication. + 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 +84,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 exact HTTP 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 +105,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() @@ -256,7 +298,10 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), - KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => { + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE => { Ok(Scope::AdminChannels) } // NIP-43: relay membership admin commands (9030–9032) + Buzz @@ -495,6 +540,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_EVENT | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE | KIND_NIP29_LEAVE_REQUEST // Huddle lifecycle events + guidelines | KIND_HUDDLE_STARTED @@ -544,6 +590,18 @@ pub(crate) async fn check_channel_membership( } } +fn uses_generic_channel_write_authority(kind: u32) -> bool { + !matches!( + kind, + KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_CREATE_GROUP + | KIND_STREAM_MESSAGE_EDIT + | KIND_NIP29_EDIT_METADATA + | KIND_NIP29_DELETE_EVENT + | KIND_NIP29_DELETE_GROUP + ) +} + fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(), String> { if let Some(allowed) = auth.channel_ids() { if !allowed.contains(&channel_id) { @@ -862,6 +920,64 @@ async fn validate_edit_ownership( Ok(()) } +/// Repeat stream-edit target, membership, and agent-owner validation while the +/// common protected operation transaction owns all relevant database locks. +async fn validate_edit_ownership_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &Event, + state: &AppState, +) -> Result<(), String> { + let target_hex = event + .tags + .iter() + .find_map(|tag| { + (tag.kind().to_string() == "e") + .then(|| tag.content()) + .flatten() + }) + .filter(|value| { + value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .ok_or_else(|| "missing e tag for edit target".to_string())?; + let target_bytes = + hex::decode(target_hex).map_err(|_| "invalid target event ID".to_string())?; + let target_event = buzz_db::event::get_event_by_id_tx(transaction, community_id, &target_bytes) + .await + .map_err(|error| format!("db error: {error}"))? + .ok_or_else(|| "edit target event not found".to_string())?; + + let edit_channel_id = extract_channel_id(event); + match (edit_channel_id, target_event.channel_id) { + (Some(edit_channel), Some(target_channel)) if edit_channel != target_channel => { + return Err("target event belongs to a different channel".to_string()); + } + (Some(_), None) => return Err("target event has no channel".to_string()), + _ => {} + } + + let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + let actor = event.pubkey.to_bytes().to_vec(); + if author == actor { + if let Some(channel_id) = target_event.channel_id { + buzz_db::channel::require_channel_write_authority_tx( + transaction, + community_id, + channel_id, + &actor, + ) + .await + .map_err(|error| format!("restricted: channel authority changed: {error}"))?; + } + } else if !buzz_db::user::is_agent_owner_tx(transaction, community_id, &author, &actor) + .await + .map_err(|error| format!("db error checking agent ownership: {error}"))? + { + return Err("must be event author to edit".to_string()); + } + Ok(()) +} + /// Validate kind:45002 vote targets a forum post (45001) or comment (45003). async fn validate_forum_vote_target( community_id: CommunityId, @@ -1908,18 +2024,135 @@ async fn ingest_event_inner( ))); } + let protected_result = match auth.verified_proof() { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + Arc::clone(proof), + auth.verified_assertion().cloned(), + crate::protected_surface::event_ingest_capability(kind_u32), + stable_event_correlation(&event), + "event_ingest", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result.map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization denied: {error}" + )) + })?; + if protected.is_enforcing() + && crate::protected_surface::event_mutation_disposition(kind_u32) + != crate::protected_surface::EventMutationDisposition::TransactionalPersistence + { + return Err(IngestError::AuthFailed( + "restricted: protected event mutation unavailable".into(), + )); + } + let mut non_enforcing_postgresql_git = false; + let legacy_git_policy_guard = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT + && !protected.is_enforcing() + { + let object_authority = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let repo_id = protected_git_repo_id(&event)?; + let owner = hex::encode(event.pubkey.to_bytes()); + match object_authority.state { + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Legacy => { + if state + .db + .repo_publication_origin(tenant.community(), &repo_id, &owner) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))? + .as_deref() + == Some("protected_unpublished") + { + return Err(IngestError::AuthFailed( + "restricted: protected Git reservation cannot enter the legacy lane".into(), + )); + } + let guard = state + .db + .begin_legacy_visibility_write( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is fenced: {error}" + )) + })?; + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is permanently fenced: {error}" + )) + })?; + Some(guard) + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql => { + crate::api::git::migration::require_reconciled_authority(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: PostgreSQL Git policy is unavailable: {error}" + )) + })?; + non_enforcing_postgresql_git = true; + None + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Importing => { + return Err(IngestError::AuthFailed( + "restricted: Git policy migration is incomplete".into(), + )); + } + } + } else { + None + }; + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { - return super::command_executor::handle_command(tenant, state, event, auth).await; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + return super::command_executor::handle_command(tenant, state, event, auth, &protected) + .await; } // Product feedback is sidecarred directly into its private deployment table. // It never enters ordinary event storage or subscription fan-out. if kind_u32 == KIND_PRODUCT_FEEDBACK { - super::product_feedback::handle(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::product_feedback::handle_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::product_feedback::handle(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } // Feedback is a host-resolved, channel-less write. Although its row is // private to operator tooling rather than ordinary event reads, this is // the matching modeled success action at the ingest isolation seam. @@ -1938,9 +2171,20 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::report::handle_report_event_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::report::handle_report_event(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -1956,9 +2200,22 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::moderation_commands::handle_moderation_command_enforced( + tenant, state, &event, &protected, + ) .await .map_err(IngestError::Rejected)?; + } else { + super::moderation_commands::handle_moderation_command(tenant, state, &event) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2129,7 +2386,7 @@ async fn ingest_event_inner( // row is missing (global event, kind:9007 pre-create) this is `None` and // fan-out performs its own fresh fail-closed lookup — `None` is never // "assume open" (fence 1). - let threaded_visibility = match (channel_id, &channel_row) { + let mut threaded_visibility = match (channel_id, &channel_row) { (Some(ch_id), Some(row)) => state .channel_visibility_cached(tenant.community(), ch_id, Some(row)) .await @@ -2149,13 +2406,7 @@ async fn ingest_event_inner( // member/open gate here lets the owning human act on private agent channels // without being a member (OQ1 decision; see validate_edit_ownership / // validate_admin_event for per-kind enforcement). - let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST - || kind_u32 == KIND_NIP29_CREATE_GROUP - || kind_u32 == KIND_STREAM_MESSAGE_EDIT - || kind_u32 == KIND_NIP29_EDIT_METADATA - || kind_u32 == KIND_NIP29_DELETE_EVENT - || kind_u32 == KIND_NIP29_DELETE_GROUP; - if !skip_membership { + if uses_generic_channel_write_authority(kind_u32) { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag // (recorded separately to bite M2 / M8 — claim or A-host @@ -2191,9 +2442,22 @@ async fn ingest_event_inner( // gate above exempts relay-admin kinds so timed-out admins keep their // administrative capability, which leaves bans to the handler. if is_relay_admin_kind(event.kind.as_u16() as u32) { - crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + crate::handlers::relay_admin::handle_relay_admin_event_enforced( + tenant, state, &event, &protected, + ) .await .map_err(map_relay_admin_error)?; + } else { + crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + .await + .map_err(map_relay_admin_error)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2238,11 +2502,69 @@ async fn ingest_event_inner( let sender_hex = event.pubkey.to_hex(); // remove_relay_member handles both the NotFound and IsOwner cases atomically. - let remove_result = state - .db - .remove_relay_member(tenant.community(), &sender_hex) - .await - .map_err(|e| IngestError::Internal(format!("database error: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let remove_result = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.leave.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-leave-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.leave.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + if payload.as_slice() != b"left" { + return Err(IngestError::Internal( + "error: protected relay-leave receipt is invalid".into(), + )); + } + buzz_db::relay_members::RemoveResult::Removed + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let result = buzz_db::relay_members::remove_relay_member_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ) + .await + .map_err(|error| IngestError::Internal(format!("database error: {error}")))?; + if result == buzz_db::relay_members::RemoveResult::Removed { + operation.commit(b"left").await.map_err(|error| { + IngestError::AuthFailed(format!("restricted: {error}")) + })?; + } + result + } + } + } else { + state + .db + .remove_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| IngestError::Internal(format!("database error: {e}")))? + }; match remove_result { buzz_db::relay_members::RemoveResult::Removed => {} @@ -2265,20 +2587,27 @@ async fn ingest_event_inner( } } - // Publish NIP-43 announcements — fire-and-forget. - if let Err(e) = - crate::handlers::side_effects::publish_nip43_member_removed(tenant, state, &sender_hex) - .await - { - warn!(error = %e, "failed to publish NIP-43 member removed event"); - } - if let Err(e) = - crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await - { - warn!(error = %e, "failed to publish NIP-43 membership list"); + // Relay-signed announcements are derived background effects. Preserve + // them in legacy modes, but keep them unavailable before execution in + // Enforce until they have an authoritative delivery model. + if !protected.is_enforcing() { + if let Err(e) = crate::handlers::side_effects::publish_nip43_member_removed( + tenant, + state, + &sender_hex, + ) + .await + { + warn!(error = %e, "failed to publish NIP-43 member removed event"); + } + if let Err(e) = + crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await + { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } } - info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request"); + info!("relay member left via NIP-43 leave request"); return Ok(IngestResult { event_id: event_id_hex, @@ -2298,9 +2627,16 @@ async fn ingest_event_inner( // NIP-43 admin commands above — the request itself falls through to normal // storage so the delta's `["e", request_id]` audit reference resolves. if is_identity_archive_request_kind(kind_u32) { - crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) - .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if !protected.is_enforcing() { + crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } } if kind_u32 == KIND_DELETION { @@ -2479,50 +2815,57 @@ async fn ingest_event_inner( IngestError::Rejected(format!("invalid channel_type: {channel_type_str}")) })?; - if let Some(client_uuid) = channel_id { - let name = create_name.unwrap_or_default(); - let name = buzz_core::channel::canonical_channel_name(&name); + if !protected.is_enforcing() { + if let Some(client_uuid) = channel_id { + let name = create_name.unwrap_or_default(); + let name = buzz_core::channel::canonical_channel_name(&name); - let description = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "about" { - t.content().map(|s| s.to_string()) - } else { - None - } - }); + let description = event.tags.iter().find_map(|t| { + if t.kind().to_string() == "about" { + t.content().map(|s| s.to_string()) + } else { + None + } + }); - let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); + let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); - let actor_bytes = event.pubkey.to_bytes().to_vec(); - let (_, was_created) = state - .db - .create_channel_with_id( - tenant.community(), - client_uuid, - name, - channel_type, - visibility, - description.as_deref(), - &actor_bytes, - ttl_seconds, + let actor_bytes = event.pubkey.to_bytes().to_vec(); + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let (_, was_created) = state + .db + .create_channel_with_id( + tenant.community(), + client_uuid, + name, + channel_type, + visibility, + description.as_deref(), + &actor_bytes, + ttl_seconds, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + + if !was_created { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message: "duplicate: channel already exists".into(), + }); + } + pre_created_channel = Some(client_uuid); + metrics::counter!( + "buzz_channels_created_total", + "community" => crate::metrics::community_label(tenant.community()), + "type" => channel_type.to_string() ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; - - if !was_created { - return Ok(IngestResult { - event_id: event_id_hex, - accepted: false, - message: "duplicate: channel already exists".into(), - }); + .increment(1); } - pre_created_channel = Some(client_uuid); - metrics::counter!( - "buzz_channels_created_total", - "community" => tenant.host().to_owned(), - "type" => channel_type.to_string() - ) - .increment(1); } } @@ -2549,6 +2892,11 @@ async fn ingest_event_inner( } if kind_u32 == super::push_lease::KIND_PUSH_LEASE { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; let outcome = super::push_lease::accept(tenant, state, &event, now) .await .map_err(map_push_accept_error)?; @@ -2688,20 +3036,116 @@ async fn ingest_event_inner( // the event in the same transaction. Ordering is load-bearing: active // duplicate reactions must return before storing a duplicate kind:7 event. let thread_params = thread_meta.as_ref().map(|m| m.as_params()); - let (stored_event, was_inserted) = match state - .db - .insert_reaction_event_with_thread_metadata( - tenant.community(), - &event, - channel_id, - thread_params, - &target_id, - &actor_bytes, - emoji, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? - { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let outcome = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.reaction.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-reaction-request-v1"); + request.update(event.id.as_bytes()); + request.update(&target_id); + request.update(&actor_bytes); + request.update(emoji.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "event.reaction.v1", + request.finalize().into(), + ) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + let message = match payload.as_slice() { + b"inserted" => String::new(), + b"duplicate" => "duplicate: reaction already exists".to_owned(), + _ => { + return Err(IngestError::Internal( + "error: protected reaction receipt is invalid".into(), + )); + } + }; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: payload.as_slice() == b"inserted", + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if let Some(channel_id) = channel_id { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let outcome = buzz_db::event::insert_reaction_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + let receipt: &[u8] = match &outcome { + buzz_db::ReactionEventInsertOutcome::Inserted { .. } => b"inserted", + buzz_db::ReactionEventInsertOutcome::Duplicate => b"duplicate", + buzz_db::ReactionEventInsertOutcome::TargetMissing => { + return Err(IngestError::Rejected( + "invalid: reaction target event not found".into(), + )); + } + }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + outcome + } + } + } else { + state + .db + .insert_reaction_event_with_thread_metadata( + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + }; + let (stored_event, was_inserted) = match outcome { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected( "invalid: reaction target event not found".into(), @@ -2759,7 +3203,253 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let mut enforced_nip29_outcome = None; + let (stored_event, was_inserted) = if protected.is_enforcing() { + let thread_params = thread_meta.as_ref().map(|metadata| metadata.as_params()); + let mut stable = Sha256::new(); + stable.update(b"buzz-event-ingest-operation-v1"); + stable.update(tenant.community().as_uuid().as_bytes()); + stable.update(event.id.as_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.ingest.v1", + &stable, + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-ingest-request-v1"); + request.update(event.id.as_bytes()); + request.update(kind_u32.to_be_bytes()); + if let Some(channel_id) = channel_id { + request.update(channel_id.as_bytes()); + } + let permit = protected + .seal_postgres_mutation(operation_id, "event.ingest.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + let was_inserted = match payload.as_slice() { + b"inserted" => true, + b"duplicate" => false, + _ => { + return Err(IngestError::Internal( + "error: protected event receipt is invalid".into(), + )); + } + }; + let message = if was_inserted { + String::new() + } else { + "duplicate:".to_owned() + }; + let action = match (channel_id, was_inserted) { + (Some(channel), true) => TraceAction::WriteInsert { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (Some(channel), false) => TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed_community_from_event(&event), + }, + }; + emit(tracer, action, state_for_request(tenant, auth.pubkey())); + if let Some(outcome) = replay_nip29_outcome(kind_u32, channel_id, &event) { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + } + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if is_identity_archive_request_kind(kind_u32) { + crate::handlers::identity_archive::handle_identity_archive_event_tx( + tenant, + state, + &event, + operation.transaction(), + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if kind_u32 == KIND_STREAM_MESSAGE_EDIT { + validate_edit_ownership_tx( + operation.transaction(), + tenant.community(), + &event, + state, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if let Some(channel_id) = + channel_id.filter(|_| uses_generic_channel_write_authority(kind_u32)) + { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let result = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT { + let repo_id = protected_git_repo_id(&event)?; + buzz_db::git_repo::replace_protected_announcement_tx( + operation.transaction(), + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + } else if buzz_core::kind::is_replaceable(kind_u32) { + buzz_db::event::replace_addressable_event_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + } else if is_parameterized_replaceable(kind_u32) { + let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); + if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(IngestError::Rejected(format!( + "invalid: d tag too long ({} bytes, max {})", + d_tag.len(), + buzz_db::event::D_TAG_MAX_LEN, + ))); + } + buzz_db::event::replace_parameterized_event_tx( + operation.transaction(), + tenant.community(), + &event, + &d_tag, + channel_id, + ) + .await + } else { + buzz_db::event::insert_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + ) + .await + } + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if result.1 { + buzz_db::insert_mentions_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + } + if result.1 && matches!(kind_u32, KIND_PROFILE | KIND_AGENT_PROFILE) { + apply_profile_projection_tx(operation.transaction(), tenant, kind_u32, &event) + .await?; + } + if result.1 && kind_u32 == KIND_DELETION { + let actor = effective_message_author(&event, &state.relay_keypair.public_key()); + buzz_db::event::apply_standard_deletion_tx( + operation.transaction(), + tenant.community(), + &event, + &actor, + state.relay_keypair.public_key().as_bytes(), + ) + .await + .map_err(map_nip29_projection_error)?; + } + if result.1 && kind_u32 == KIND_NIP29_DELETE_EVENT { + buzz_db::event::apply_nip29_delete_event_tx( + operation.transaction(), + tenant.community(), + &event, + event.pubkey.to_bytes().as_slice(), + state.relay_keypair.public_key().as_bytes(), + channel_id.ok_or_else(|| { + IngestError::Rejected( + "invalid: channel deletion requires an h tag".into(), + ) + })?, + ) + .await + .map_err(map_nip29_projection_error)?; + } else if result.1 { + if let Some(mutation) = + protected_nip29_mutation(kind_u32, channel_id, &event, state)? + { + enforced_nip29_outcome = Some( + buzz_db::channel::apply_nip29_mutation_tx( + operation.transaction(), + tenant.community(), + event.pubkey.to_bytes().as_slice(), + mutation, + ) + .await + .map_err(map_nip29_projection_error)?, + ); + } + } + let receipt: &[u8] = if result.1 { b"inserted" } else { b"duplicate" }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + result + } + } + } else if non_enforcing_postgresql_git { + let repo_id = protected_git_repo_id(&event)?; + let mut transaction = state + .db + .begin_transaction() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let result = buzz_db::git_repo::replace_protected_announcement_tx( + &mut transaction, + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + .map_err(map_nip29_projection_error)?; + transaction + .commit() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + result + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -2826,7 +3516,10 @@ async fn ingest_event_inner( }); } - if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + if !protected.is_enforcing() + && !non_enforcing_postgresql_git + && crate::handlers::side_effects::is_side_effect_kind(kind_u32) + { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await @@ -2839,19 +3532,34 @@ async fn ingest_event_inner( error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}"); } } + if let Some(guard) = legacy_git_policy_guard { + guard + .commit() + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + } + + if let Some(outcome) = enforced_nip29_outcome { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + if outcome.channel_changed { + threaded_visibility = None; + } + } // A freshly inserted reply changed its thread's counters (updated in the // same transaction as the insert) — push a fresh relay-signed 39005 so // subscribed clients can update badge counts without refetching the head // window. Page responses recompute summaries independently, so this is // fan-out-only and best-effort. - if let Some(meta) = &thread_meta { - crate::handlers::side_effects::emit_live_thread_summary( - tenant, - state, - meta.channel_id, - meta.root_event_id.clone(), - ); + if !protected.is_enforcing() { + if let Some(meta) = &thread_meta { + crate::handlers::side_effects::emit_live_thread_summary( + tenant, + state, + meta.channel_id, + meta.root_event_id.clone(), + ); + } } let pubkey_hex = auth.pubkey().to_hex(); @@ -2903,6 +3611,318 @@ async fn ingest_event_inner( }) } +fn stable_event_correlation(event: &Event) -> Uuid { + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&event.id.as_bytes()[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +fn replay_nip29_outcome( + kind: u32, + channel_id: Option, + event: &Event, +) -> Option { + let (channel_id, membership_changed, channel_changed) = match kind { + KIND_NIP29_CREATE_GROUP => ( + channel_id.unwrap_or_else(|| stable_event_correlation(event)), + true, + true, + ), + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_LEAVE_REQUEST => (channel_id?, true, true), + KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_GROUP => (channel_id?, false, true), + _ => return None, + }; + Some(buzz_db::channel::Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed, + channel_changed, + }) +} + +async fn apply_enforced_nip29_postcommit( + tenant: &TenantContext, + state: &Arc, + kind: u32, + event: &Event, + outcome: buzz_db::channel::Nip29MutationOutcome, +) { + if outcome.membership_changed { + let member = match kind { + KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER => extract_p_tag_bytes(event).ok(), + KIND_NIP29_CREATE_GROUP | KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => { + Some(event.pubkey.to_bytes().to_vec()) + } + _ => None, + }; + if let Some(member) = member { + state.invalidate_membership(tenant, outcome.channel_id, &member); + if matches!(kind, KIND_NIP29_REMOVE_USER | KIND_NIP29_LEAVE_REQUEST) { + crate::handlers::side_effects::evict_live_channel_subscriptions( + tenant, + state, + outcome.channel_id, + &member, + ) + .await; + } + } + state.invalidate_all_accessible_channels(tenant); + } + if outcome.channel_changed { + state.invalidate_channel_visibility(tenant, outcome.channel_id); + if kind == KIND_NIP29_DELETE_GROUP { + state.invalidate_channel_deleted(tenant); + crate::handlers::side_effects::evict_all_channel_subscriptions( + tenant, + state, + outcome.channel_id, + ) + .await; + } + } +} + +async fn apply_profile_projection_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + kind: u32, + event: &Event, +) -> Result<(), IngestError> { + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|error| IngestError::Rejected(format!("invalid: profile content: {error}")))?; + let pubkey = event.pubkey.to_bytes(); + buzz_db::user::ensure_user_tx(transaction, tenant.community(), pubkey.as_slice()) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if kind == KIND_AGENT_PROFILE { + let policy = content + .get("channel_add_policy") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + IngestError::Rejected("invalid: agent profile missing channel_add_policy".into()) + })?; + buzz_db::user::set_channel_add_policy_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + policy, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + return Ok(()); + } + let display_name = content + .get("display_name") + .or_else(|| content.get("name")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let avatar_url = content + .get("picture") + .or_else(|| content.get("image")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let about = content + .get("about") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let nip05 = content + .get("nip05") + .and_then(serde_json::Value::as_str) + .and_then(|value| crate::api::nip05::canonicalize_nip05(value, tenant.host()).ok()) + .unwrap_or_default(); + buzz_db::user::replace_user_profile_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + display_name, + avatar_url, + about, + &nip05, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}"))) +} + +fn protected_nip29_mutation( + kind: u32, + channel_id: Option, + event: &Event, + state: &Arc, +) -> Result, IngestError> { + use buzz_db::channel::{ + ChannelType, ChannelUpdate, ChannelVisibility, MemberRole, Nip29Mutation, + }; + + let tag = |name: &str| { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some(name)) + .then(|| parts.get(1).cloned()) + .flatten() + }) + }; + let required_channel = + || channel_id.ok_or_else(|| IngestError::Rejected("invalid: missing h tag".into())); + let mutation = match kind { + KIND_NIP29_CREATE_GROUP => { + let name = tag("name") + .ok_or_else(|| IngestError::Rejected("invalid: channel name is required".into()))?; + let channel_type = tag("channel_type") + .unwrap_or_else(|| "stream".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel type".into()))?; + let visibility = tag("visibility") + .unwrap_or_else(|| "open".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel visibility".into()))?; + Nip29Mutation::Create { + channel_id: channel_id.unwrap_or_else(|| stable_event_correlation(event)), + name, + channel_type, + visibility, + description: tag("about"), + ttl_seconds: super::resolve_ttl(event, state.config.ephemeral_ttl_override), + } + } + KIND_NIP29_PUT_USER => { + let target = extract_p_tag_bytes(event)?; + let role = tag("role") + .map(|role| { + role.parse::() + .map_err(|_| IngestError::Rejected("invalid: member role".into())) + }) + .transpose()?; + Nip29Mutation::PutUser { + channel_id: required_channel()?, + target, + role, + } + } + KIND_NIP29_REMOVE_USER => Nip29Mutation::RemoveUser { + channel_id: required_channel()?, + target: extract_p_tag_bytes(event)?, + }, + KIND_NIP29_EDIT_METADATA => { + let ttl_value = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("ttl")).then(|| parts.get(1).cloned()) + }); + let ttl_seconds = match ttl_value { + None => None, + Some(None) => { + return Err(IngestError::Rejected( + "invalid: channel ttl must have a value".into(), + )); + } + Some(Some(value)) if value.is_empty() => Some(None), + Some(Some(value)) => { + Some(Some(value.parse::().map_err(|_| { + IngestError::Rejected("invalid: channel ttl".into()) + })?)) + } + }; + let archived = tag("archived") + .map(|value| match value.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(IngestError::Rejected("invalid: archive state".into())), + }) + .transpose()?; + Nip29Mutation::EditMetadata { + channel_id: required_channel()?, + updates: ChannelUpdate { + name: tag("name"), + description: tag("about"), + visibility: tag("visibility"), + ttl_seconds, + }, + topic: tag("topic"), + purpose: tag("purpose"), + archived, + } + } + KIND_NIP29_DELETE_GROUP => Nip29Mutation::DeleteGroup { + channel_id: required_channel()?, + relay_pubkey: state.relay_keypair.public_key().to_bytes().to_vec(), + }, + KIND_NIP29_JOIN_REQUEST => Nip29Mutation::Join { + channel_id: required_channel()?, + }, + KIND_NIP29_LEAVE_REQUEST => Nip29Mutation::Leave { + channel_id: required_channel()?, + }, + _ => return Ok(None), + }; + Ok(Some(mutation)) +} + +fn extract_p_tag_bytes(event: &Event) -> Result, IngestError> { + let value = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| IngestError::Rejected("invalid: missing p tag".into()))?; + let bytes = + hex::decode(value).map_err(|_| IngestError::Rejected("invalid: malformed p tag".into()))?; + if bytes.len() != 32 { + return Err(IngestError::Rejected("invalid: malformed p tag".into())); + } + Ok(bytes) +} + +fn map_nip29_projection_error(error: buzz_db::DbError) -> IngestError { + match error { + buzz_db::DbError::AccessDenied(message) + | buzz_db::DbError::InvalidData(message) + | buzz_db::DbError::NotFound(message) => { + IngestError::Rejected(format!("invalid: {message}")) + } + buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::MemberNotFound(_) => { + IngestError::Rejected("invalid: channel state changed".into()) + } + other => IngestError::Internal(format!("error: {other}")), + } +} + +fn protected_git_repo_id(event: &Event) -> Result { + let repo_id = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| { + IngestError::Rejected("invalid: repository announcement missing d tag".into()) + })?; + if repo_id.is_empty() + || repo_id.len() > 64 + || repo_id.starts_with('.') + || repo_id.contains("..") + || !repo_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + { + return Err(IngestError::Rejected( + "invalid: repository identifier is not portable".into(), + )); + } + Ok(repo_id.to_owned()) +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -2996,6 +4016,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, }; @@ -3073,6 +4096,25 @@ mod tests { assert!(!requires_h_channel_scope(KIND_NIP29_CREATE_GROUP)); } + #[test] + fn protected_nip29_receipt_replay_restores_the_required_cache_fences() { + let event = make_dummy_event(); + let created = replay_nip29_outcome(KIND_NIP29_CREATE_GROUP, None, &event) + .expect("create-group receipts need replay fences"); + assert_eq!(created.channel_id, stable_event_correlation(&event)); + assert!(created.membership_changed); + assert!(created.channel_changed); + + let channel_id = Uuid::new_v4(); + let removed = replay_nip29_outcome(KIND_NIP29_REMOVE_USER, Some(channel_id), &event) + .expect("remove-user receipts need replay fences"); + assert_eq!(removed.channel_id, channel_id); + assert!(removed.membership_changed); + assert!(removed.channel_changed); + + assert!(replay_nip29_outcome(KIND_TEXT_NOTE, Some(channel_id), &event).is_none()); + } + #[test] fn join_request_does_not_require_h_tag_via_requires_h() { // kind:9021 uses h-tag for channel reference but doesn't go through @@ -3398,6 +4440,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 +4461,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 +4479,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/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 3d4b7f4a0a..8956b2ba72 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; @@ -102,7 +103,7 @@ pub async fn authorize_moderation_action( // The target's community role is read only for the admin guard rail — i.e. // an admin actioning a pubkey with ban/timeout — so the owner and // channel-role paths stay at a single query. - let target_role = match (actor_role.as_deref(), action, target) { + let target_role: Option = match (actor_role.as_deref(), action, target) { (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { match target { ModerationTarget::Pubkey(pk) => state @@ -118,7 +119,7 @@ pub async fn authorize_moderation_action( // The channel role is read only when community authority does not apply and // the action is channel-local (DeleteMessage/Kick within `channel_id`). - let channel_role = match (actor_role.as_deref(), action, channel_id) { + let channel_role: Option = match (actor_role.as_deref(), action, channel_id) { (Some("owner") | Some("admin"), _, _) => None, (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { state @@ -137,6 +138,77 @@ 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 { + // Moderation is rare. Table SHARE locks close the absent-row race as well + // as update/delete races: every role insert/update/delete takes the + // conflicting ROW EXCLUSIVE lock before it can commit. + 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/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index c769ac3cb9..1dd66920f2 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -65,6 +65,7 @@ use buzz_core::kind::{ use buzz_core::tenant::TenantContext; use chrono::{DateTime, TimeZone, Utc}; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::info; use uuid::Uuid; @@ -132,6 +133,495 @@ pub async fn handle_moderation_command( } } +/// Execute a moderation command in the protected PostgreSQL authorization +/// transaction. Durable moderation state, audit state, and the idempotency +/// receipt commit together; notices and disconnects are derived delivery after +/// that authoritative commit. +pub async fn handle_moderation_command_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let actor = event.pubkey.to_bytes().to_vec(); + validate_command_admission(tenant, state, event, &actor).await?; + let command = ProtectedModerationCommand::parse(event)?; + command.authorize(tenant, state, &actor).await?; + + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.command.v1", + event.id.as_bytes(), + ) + .map_err(|execution_error| error(execution_error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.command.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + let post_commit = + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"moderated" { + return Err(error("protected moderation receipt is invalid")); + } + None + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + command + .authorize_tx(operation.transaction(), tenant, &actor) + .await?; + let post_commit = command + .execute(operation.transaction(), tenant, &actor) + .await?; + operation + .commit(b"moderated") + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))?; + Some(post_commit) + } + }; + + if let Some(post_commit) = post_commit { + post_commit.deliver_enforced(tenant, state, event).await; + } + Ok(()) +} + +async fn validate_command_admission( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let restriction = state + .db + .moderation_restriction_state(tenant.community(), actor) + .await + .map_err(|e| error(format!("database error checking restriction state: {e}")))?; + ensure_actor_not_banned(&restriction)?; + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + return Err(invalid(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + event_ts - now + ))); + } + Ok(()) +} + +enum ProtectedModerationCommand { + Ban { + target: Vec, + expires_at: Option>, + reason: Option, + }, + Unban { + target: Vec, + }, + Timeout { + target: Vec, + muted_until: DateTime, + reason: Option, + }, + Untimeout { + target: Vec, + }, + Resolve { + report_event_id: Vec, + status: String, + action: String, + reason: Option, + }, +} + +impl ProtectedModerationCommand { + fn parse(event: &Event) -> Result { + match event.kind.as_u16() as u32 { + KIND_MODERATION_BAN => Ok(Self::Ban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + expires_at: extract_expiration(event)?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNBAN => Ok(Self::Unban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_TIMEOUT => Ok(Self::Timeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + muted_until: extract_expiration(event)? + .ok_or_else(|| invalid("timeout requires an expiration tag"))?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNTIMEOUT => Ok(Self::Untimeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_RESOLVE_REPORT => { + let report_event_id = extract_report_tag(event).ok_or_else(|| { + invalid("missing or invalid report tag (expect 64-hex event id)") + })?; + let status = extract_tag_value(event, "status") + .ok_or_else(|| invalid("missing status tag"))?; + let action = extract_tag_value(event, "action") + .ok_or_else(|| invalid("missing action tag"))?; + validate_resolution(&status, &action)?; + Ok(Self::Resolve { + report_event_id, + status, + action, + reason: extract_tag_value(event, "reason"), + }) + } + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), + } + } + + async fn authorize( + &self, + tenant: &TenantContext, + state: &Arc, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + authorize_moderation_action(tenant, state, actor, None, target, action) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn authorize_tx( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + super::moderation_authz::authorize_moderation_action_tx( + transaction, + tenant, + actor, + None, + target, + action, + ) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn execute( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result { + let community = tenant.community(); + match self { + Self::Ban { + target, + expires_at, + reason, + } => { + buzz_db::moderation::ban_member_tx( + transaction, + community, + target, + actor, + reason.as_deref(), + *expires_at, + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "ban", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::Ban { + target: target.clone(), + }) + } + Self::Unban { target } => { + if !buzz_db::moderation::unban_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not banned")); + } + insert_audit_tx( + transaction, + community, + actor, + "unban", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Timeout { + target, + muted_until, + reason, + } => { + buzz_db::moderation::timeout_member_tx( + transaction, + community, + target, + actor, + *muted_until, + reason.as_deref(), + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "timeout", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Untimeout { target } => { + if !buzz_db::moderation::untimeout_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not timed out")); + } + insert_audit_tx( + transaction, + community, + actor, + "untimeout", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Resolve { + report_event_id, + status, + action, + reason, + } => { + let report = buzz_db::moderation::get_report_by_event_tx( + transaction, + community, + report_event_id, + ) + .await + .map_err(moderation_db_error)? + .ok_or_else(|| invalid("report not found in this community"))?; + if report.status != "open" { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + let (target_pubkey, target_event_id) = match &report.target { + buzz_db::moderation::ReportTarget::Pubkey(pubkey) => { + (Some(pubkey.as_slice()), None) + } + buzz_db::moderation::ReportTarget::Event(event_id) => { + (None, Some(event_id.as_slice())) + } + buzz_db::moderation::ReportTarget::Blob(_) => (None, None), + }; + let action_id = insert_audit_tx( + transaction, + community, + actor, + resolution_audit_action(action), + target_pubkey, + target_event_id, + reason.as_deref(), + ) + .await?; + if !buzz_db::moderation::resolve_report_tx( + transaction, + community, + report.id, + status, + actor, + Some(action_id), + ) + .await + .map_err(moderation_db_error)? + { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + Ok(ModerationPostCommit::Resolve { + report_id: report.id, + status: status.clone(), + action: action.clone(), + }) + } + } + } +} + +enum ModerationPostCommit { + None, + Ban { + target: Vec, + }, + Resolve { + report_id: Uuid, + status: String, + action: String, + }, +} + +impl ModerationPostCommit { + /// Apply only effects that are safe after the transaction-owned Enforce + /// commit. Relay-signed notice delivery is intentionally unavailable here: + /// it can create or unhide a DM and persist helper events, so treating it as + /// derived delivery would reopen an unfenced protected mutation path. + async fn deliver_enforced(self, tenant: &TenantContext, state: &Arc, event: &Event) { + match self { + Self::None => {} + Self::Ban { target } => { + state.disconnect_pubkey_clusterwide( + tenant, + &target, + &event.id.to_hex(), + "blocked: you are banned from this community", + ); + } + Self::Resolve { + report_id, + status, + action, + } => { + info!(%report_id, %status, %action, "report resolved"); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn insert_audit_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: buzz_core::CommunityId, + actor: &[u8], + action: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + public_reason: Option<&str>, +) -> Result { + buzz_db::moderation::insert_action_tx( + transaction, + community, + NewAction { + actor_pubkey: actor, + action, + target_pubkey, + target_event_id, + channel_id: None, + reason_code: None, + public_reason, + private_reason: None, + matched_principal: None, + }, + ) + .await + .map_err(|database_error| error(format!("failed to write audit row: {database_error}"))) +} + +fn moderation_db_error(database_error: buzz_db::DbError) -> String { + error(format!("database error: {database_error}")) +} + +fn validate_resolution(status: &str, action: &str) -> Result<(), String> { + if status != "resolved" && status != "dismissed" { + return Err(invalid(format!( + "invalid status: {status} (expect resolved|dismissed)" + ))); + } + if !matches!( + action, + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(invalid(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + ))); + } + if (action == "dismiss") != (status == "dismissed") { + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); + } + Ok(()) +} + fn ensure_actor_not_banned( restriction: &buzz_db::moderation::RestrictionState, ) -> Result<(), String> { diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index 8f57eea71f..0cbf0ae394 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -112,7 +112,7 @@ pub async fn send_moderation_notice( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e194..3b77b5c8f0 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::product_feedback::NewProductFeedback; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -18,6 +19,101 @@ pub async fn handle( event: &Event, state: &Arc, ) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + state + .db + .insert_product_feedback( + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; + + Ok(()) +} + +/// Validate and persist feedback at the transaction-owned protected commit +/// boundary. A retry observes the original receipt and never writes twice. +pub async fn handle_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "product.feedback.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-product-feedback-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "product.feedback.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"accepted" { + return Err("error: protected feedback receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::product_feedback::insert_tx( + operation.transaction(), + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|error| { + format!("error: database error inserting product feedback: {error}") + })?; + operation + .commit(b"accepted") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +async fn validate<'a>( + tenant: &TenantContext, + event: &'a Event, + state: &Arc, +) -> Result< + ( + Option<&'a str>, + serde_json::Value, + chrono::DateTime, + ), + String, +> { let category = parse_category(event)?; validate_body(&event.content)?; let imeta_tags = event @@ -38,23 +134,7 @@ pub async fn handle( chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) .ok_or_else(|| "invalid: feedback timestamp is out of range".to_string())?; - state - .db - .insert_product_feedback( - tenant.community(), - NewProductFeedback { - event_id: event.id.as_bytes(), - submitter_pubkey: &event.pubkey.to_bytes(), - category, - body: &event.content, - tags: &tags, - event_created_at, - }, - ) - .await - .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; - - Ok(()) + Ok((category, tags, event_created_at)) } fn serialize_tags(event: &Event) -> Result { diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516..297540292c 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::{info, warn}; use buzz_core::kind::{ @@ -207,6 +208,218 @@ pub(super) async fn handle_relay_admin_event( .map_err(RelayAdminError::Rejected) } +/// Execute a relay-admin command inside the common protected authorization +/// transaction. Relay-signed roster announcements are deliberately not +/// emitted here: they are derived background effects and Enforce denies those +/// until they have their own authoritative model. +pub(super) async fn handle_relay_admin_event_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), RelayAdminError> { + enforce_freshness(event).map_err(RelayAdminError::Rejected)?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.admin.v1", + event.id.as_bytes(), + ) + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-admin-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.admin.v1", request.finalize().into()) + .map_err(|_| RelayAdminError::Rejected("protected authorization denied".into()))? + .ok_or_else(|| RelayAdminError::Rejected("protected authorization denied".into()))?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"applied" { + return Err(RelayAdminError::Internal( + "protected relay-admin receipt is invalid".into(), + )); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let restriction = buzz_db::moderation::restriction_state_tx( + operation.transaction(), + tenant.community(), + &event.pubkey.to_bytes(), + ) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + admits_relay_admin_command(&restriction)?; + execute_relay_admin_command_tx(tenant, event, operation.transaction()) + .await + .map_err(RelayAdminError::Rejected)?; + operation + .commit(b"applied") + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + } + } + Ok(()) +} + +fn enforce_freshness(event: &Event) -> Result<(), String> { + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", + event_ts - now + )); + } + Ok(()) +} + +async fn execute_relay_admin_command_tx( + tenant: &TenantContext, + event: &Event, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let sender_hex = event.pubkey.to_hex(); + let sender_member = + buzz_db::relay_members::get_relay_member_tx(transaction, tenant.community(), &sender_hex) + .await + .map_err(|error| format!("database error: {error}"))?; + let sender_role = sender_member + .as_ref() + .map(|member| member.role.as_str()) + .unwrap_or(""); + + if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let icon = extract_tag_value(event, "icon").unwrap_or_default(); + validate_workspace_icon(&icon)?; + let updated = sqlx::query("UPDATE communities SET icon = $2 WHERE id = $1") + .bind(tenant.community().as_uuid()) + .bind((!icon.is_empty()).then_some(icon.as_str())) + .execute(&mut **transaction) + .await + .map_err(|error| format!("failed to store workspace icon: {error}"))?; + if updated.rows_affected() != 1 { + return Err("community not found".into()); + } + return Ok(()); + } + + let target_hex = extract_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + match kind { + RELAY_ADMIN_ADD_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".into()); + if role == "owner" { + return Err("invalid role: use kind:9032 to promote to owner".into()); + } + if role == "admin" && sender_role != "owner" { + return Err("actor not authorized: only owner can grant admin role".into()); + } + if role != "admin" && role != "member" { + return Err(format!("invalid role: {role}")); + } + buzz_db::relay_members::add_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + &role, + Some(&sender_hex), + ) + .await + .map_err(|error| format!("database error: {error}"))?; + } + RELAY_ADMIN_REMOVE_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + if target_hex == sender_hex { + return Err("cannot remove yourself".into()); + } + let result = if sender_role == "admin" { + buzz_db::relay_members::remove_relay_member_if_role_tx( + transaction, + tenant.community(), + &target_hex, + "member", + ) + .await + } else { + buzz_db::relay_members::remove_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + } + .map_err(|error| format!("database error: {error}"))?; + match result { + RemoveResult::Removed => {} + RemoveResult::IsOwner => return Err("cannot remove the relay owner".into()), + RemoveResult::NotFound => return Err(format!("member not found: {target_hex}")), + RemoveResult::RoleMismatch => { + return Err("actor not authorized: admins can only remove members".into()) + } + } + } + RELAY_ADMIN_CHANGE_ROLE => { + if sender_role != "owner" { + return Err("actor not authorized: must be owner".into()); + } + if target_hex == sender_hex { + return Err("cannot change your own role".into()); + } + let new_role = + extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?; + if new_role == "owner" { + return Err("cannot set role to owner".into()); + } + if new_role != "admin" && new_role != "member" { + return Err(format!("invalid role: {new_role}")); + } + if !buzz_db::relay_members::update_relay_member_role_tx( + transaction, + tenant.community(), + &target_hex, + &new_role, + ) + .await + .map_err(|error| format!("database error: {error}"))? + { + let exists = buzz_db::relay_members::get_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + .map_err(|error| format!("database error: {error}"))?; + return Err(if exists.is_some() { + "cannot change the relay owner's role".into() + } else { + format!("member not found: {target_hex}") + }); + } + } + other => return Err(format!("unexpected relay admin kind: {other}")), + } + Ok(()) +} + /// Execute an already-admitted relay admin command. /// /// The handler: @@ -231,19 +444,7 @@ async fn execute_relay_admin_command( // This mirrors the NIP-42 auth event freshness check and prevents replay // of captured admin commands. The window is intentionally tight — admin // events should be freshly signed. - { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { - return Err(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", - event_ts - now - )); - } - } + enforce_freshness(event)?; let sender_member = state .db diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b..4261b228e8 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -46,6 +47,100 @@ pub async fn handle_report_event( event: &Event, state: &Arc, ) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + + state + .db + .insert_moderation_report( + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|e| format!("error: database error inserting report: {e}"))?; + + Ok(()) +} + +/// Persist a protected report and its authorization receipt atomically. +pub async fn handle_report_event_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.report.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-report-request-v1"); + request.update(event.id.as_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.report.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"reported" { + return Err("error: protected report receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::moderation::insert_report_tx( + operation.transaction(), + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|error| format!("error: database error inserting report: {error}"))?; + operation + .commit(b"reported") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +struct PreparedReport { + reporter_pubkey: Vec, + target: ReportTarget, + channel_id: Option, + report_type: String, + note: Option, +} + +impl PreparedReport { + fn as_new_report<'a>(&'a self, report_event_id: &'a [u8]) -> NewReport<'a> { + NewReport { + report_event_id, + reporter_pubkey: &self.reporter_pubkey, + target: self.target.clone(), + channel_id: self.channel_id, + report_type: &self.report_type, + note: self.note.as_deref(), + } + } +} + +async fn prepare_report( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -61,36 +156,35 @@ pub async fn handle_report_event( } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); - // Known Phase-1 limitation: the media sidecar API does not expose a - // cheap typed not-found vs transient-storage distinction here, so - // all lookup failures surface as a missing blob to the reporter. - state - .media_storage - .get_sidecar(tenant, &sha_hex) - .await - .map_err(|_| "invalid: report target blob not found".to_string())?; + if state.is_protected_enforcing(tenant.community()) { + state + .db + .media_publication(tenant.community(), &sha_hex) + .await + .map_err(|error| { + format!("error: database error resolving report target: {error}") + })? + .ok_or_else(|| "invalid: report target blob not found".to_string())?; + } else { + // Legacy modes preserve sidecar-authoritative resolution. + state + .media_storage + .get_sidecar(tenant, &sha_hex) + .await + .map_err(|_| "invalid: report target blob not found".to_string())?; + } (ReportTarget::Blob(sha256), None) } ParsedReportTarget::Pubkey { pubkey } => (ReportTarget::Pubkey(pubkey), None), }; - state - .db - .insert_moderation_report( - tenant.community(), - NewReport { - report_event_id: event.id.as_bytes(), - reporter_pubkey: &reporter_pubkey, - target, - channel_id, - report_type: parsed.report_type, - note: report_note(event), - }, - ) - .await - .map_err(|e| format!("error: database error inserting report: {e}"))?; - - Ok(()) + Ok(PreparedReport { + reporter_pubkey: reporter_pubkey.to_vec(), + target, + channel_id, + report_type: parsed.report_type.to_owned(), + note: report_note(event).map(ToOwned::to_owned), + }) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2aed12cd7f..81d874a46c 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -24,6 +24,25 @@ use crate::state::AppState; const MAX_SUBSCRIPTIONS: usize = 1024; +fn historical_event_release_fence( + state: &AppState, + conn: &ConnectionState, + channel_id: Option, + actor: &[u8], + protected: Arc, +) -> Arc { + match channel_id { + Some(channel_id) => crate::connection::queued_channel_read_authority( + state.db.clone(), + conn.tenant.community(), + channel_id, + actor.to_vec(), + Some(protected), + ), + None => crate::connection::queued_local_authority(protected), + } +} + /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. /// /// NIP-01 gives each filter its own DB query (OR semantics — see the comment at @@ -85,6 +104,38 @@ pub async fn handle_req( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_req", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected REQ authorization denied"); + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + let mut accessible_channels = if filters_are_nip43_membership_only(&filters) { metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534") .increment(1); @@ -97,7 +148,10 @@ pub async fn handle_req( Ok(ids) => ids, Err(e) => { warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -151,7 +205,10 @@ pub async fn handle_req( } Err(e) => { warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -162,10 +219,10 @@ pub async fn handle_req( token_allows, db_is_member, ) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: not a channel member", - )); + conn.send_protected( + RelayMessage::closed(&sub_id, "restricted: not a channel member"), + Arc::clone(&protected), + ); return; } } @@ -226,11 +283,21 @@ pub async fn handle_req( &conn, &state, trace_state.as_ref(), + &protected, ) .await; return; } + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + { let mut subs = conn.subscriptions.lock().await; subs.insert(sub_id.clone(), filters.clone()); @@ -323,7 +390,7 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); return; } }; @@ -395,7 +462,22 @@ pub async fn handle_req( } let msg = RelayMessage::event(&sub_id, &stored.event); - if !conn.send(msg) { + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + &state, + &conn, + stored.channel_id, + &pubkey_bytes, + Arc::clone(&protected), + ); + if !conn.send_guarded(msg, release) { return; } total_sent += 1; @@ -405,7 +487,11 @@ pub async fn handle_req( } } - conn.send(RelayMessage::eose(&sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); + } else { + conn.cancel.cancel(); + } debug!( conn_id = %conn_id, @@ -527,6 +613,7 @@ async fn handle_search_req( conn: &ConnectionState, state: &AppState, trace_state: Option<&crate::conformance::AbstractState>, + protected: &Arc, ) { // The community-wide channel scope (no #h tag on the filter). `None` means // "no accessible channels and no global access" → EOSE, exactly as the @@ -535,7 +622,7 @@ async fn handle_search_req( match build_search_channel_scope_filter(accessible_channels, include_global) { Some(scope) => scope, None => { - conn.send(RelayMessage::eose(sub_id)); + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); return; } }; @@ -723,7 +810,18 @@ async fn handle_search_req( if !seen_ids.insert(stored.event.id) { continue; } - if !conn.send(RelayMessage::event(sub_id, &stored.event)) { + if protected.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + state, + conn, + stored.channel_id, + reader_pubkey_bytes, + Arc::clone(protected), + ); + if !conn.send_guarded(RelayMessage::event(sub_id, &stored.event), release) { return; } emitted += 1; @@ -736,7 +834,11 @@ async fn handle_search_req( } } - conn.send(RelayMessage::eose(sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); + } else { + conn.cancel.cancel(); + } } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..641c35e2be 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -36,7 +36,7 @@ pub fn is_side_effect_kind(kind: u32) -> bool { matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) } -async fn evict_live_channel_subscriptions( +pub(crate) async fn evict_live_channel_subscriptions( tenant: &TenantContext, state: &Arc, channel_id: Uuid, @@ -78,7 +78,6 @@ async fn disable_departed_member_workflows( Ok(n) => { tracing::info!( channel = %channel_id, - owner = %hex::encode(target_pubkey), disabled = n, "Disabled departed member's workflows" ); @@ -89,7 +88,6 @@ async fn disable_departed_member_workflows( Err(e) => { warn!( channel = %channel_id, - owner = %hex::encode(target_pubkey), error = %e, "Failed to disable departed member's workflows — per-fire authority gate still denies" ); @@ -1179,7 +1177,7 @@ async fn handle_agent_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1188,7 +1186,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(policy, "kind:10100 channel_add_policy updated"); Ok(()) } @@ -1237,7 +1235,7 @@ async fn handle_kind0_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1261,8 +1259,7 @@ async fn handle_kind0_profile( if let Err(ref e) = result { let msg = format!("{e}"); if msg.contains("duplicate key value") || msg.contains("23505") { - warn!(pubkey = %hex::encode(&pubkey_bytes), - "kind:0 NIP-05 handle contested, syncing profile without it"); + warn!("kind:0 NIP-05 handle contested, syncing profile without it"); state .db .update_user_profile( @@ -1279,7 +1276,7 @@ async fn handle_kind0_profile( } } - info!(pubkey = %hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); + info!("kind:0 profile synced to users table"); Ok(()) } @@ -1807,7 +1804,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -1829,7 +1826,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -2520,6 +2517,24 @@ async fn handle_git_repo_announcement( event: &Event, state: &Arc, ) -> anyhow::Result<()> { + // Enforce announcements reserve their name and replace the NIP-33 event in + // the authorization-owned PostgreSQL transaction. PostgreSQL starts them + // unpublished; the first authorized push publishes the immutable manifest. + // Running the legacy pointer path here would be an unfenced dual write. + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + // The transaction share-lock spans both the name reservation and the + // object-store pointer write. Cutover takes the conflicting row lock, so + // it cannot enter `importing` while a final legacy publication is in flight. + let legacy_visibility = state + .db + .begin_legacy_visibility_write( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await?; + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant).await?; // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; @@ -2664,10 +2679,10 @@ async fn handle_git_repo_announcement( "failed to ensure manifest pointer: {pointer_err}" )); } + legacy_visibility.commit().await?; info!( repo_id = %repo_id, - owner = %owner_hex, reserved = reserved_by_this_attempt, "kind:30617 repo announced (name reserved, manifest pointer ensured)" ); @@ -2691,7 +2706,6 @@ async fn handle_git_repo_announcement( // "repo now exists" event, but clone/push still works. warn!( repo_id = %repo_id, - owner = %owner_hex, error = %e, "failed to emit initial kind:30618 ref state (non-fatal)" ); @@ -2885,6 +2899,9 @@ pub async fn reconcile_nip43_membership_snapshots(state: &Arc) -> anyh for community in communities { let community_id = buzz_core::CommunityId::from_uuid(community.id); + if state.is_protected_enforcing(community_id) { + continue; + } let host = community.host; let result = async { if !state @@ -3057,6 +3074,10 @@ pub async fn reconcile_channel_events( ) -> anyhow::Result<()> { use buzz_db::event::EventQuery; + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + let channels = state.db.list_channels(tenant.community(), None).await?; if channels.is_empty() { return Ok(()); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 904af74803..3bdd3d8b4e 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -4,6 +4,9 @@ mod admission; +/// Provider-neutral runtime authorization and bounded finalization. +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; +/// Provider-neutral inventory of every protected relay surface. +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/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..fd7ba659e8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -319,7 +319,7 @@ async fn main() -> anyhow::Result<()> { (deployment_community, config.relay_owner_pubkey.as_ref()) { match db.bootstrap_owner(community, owner_pubkey).await { - Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"), + Ok(()) => info!("Relay owner bootstrapped"), Err(e) => { if config.require_relay_membership { // Membership enforcement is on — a missing owner means no one @@ -427,7 +427,6 @@ async fn main() -> anyhow::Result<()> { "0000000000000000000000000000000000000000000000000000000000000001"; let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); tracing::warn!( - pubkey = %keys.public_key().to_hex(), "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ Set BUZZ_RELAY_PRIVATE_KEY for production." ); @@ -461,6 +460,15 @@ async fn main() -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // Protected authorization is absent unless exact domains are named in + // server configuration. When present, durable invalidation snapshots are + // initialized before the runtime becomes reachable by any transport. + if buzz_relay::authorization_runtime::production::install_from_environment(&state).await? + == buzz_relay::authorization_runtime::production::ProtectedRuntimeInstallation::Installed + { + info!("Protected authorization runtime installed"); + } + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the // kill switch is off — nothing is bound, published, or spawned, so the // relay behaves byte-identically to a build without the mesh. When @@ -480,6 +488,8 @@ async fn main() -> anyhow::Result<()> { // BUZZ_MESH_DEMO_ECHO) before peers can route traffic here. handle.wire_consumers( Arc::clone(&state.audio_rooms), + state.db.clone(), + state.relay_keypair.secret_key().as_secret_bytes(), state.config.mesh_demo_echo, Arc::clone(&state.shutting_down), ); @@ -527,6 +537,41 @@ async fn main() -> anyhow::Result<()> { ); } + // Enforce startup is verification-only for protected-object cutover. + // The resumable one-way preparation must complete before the independent + // restore anchor is provisioned; mutating PostgreSQL after anchor + // verification would create an unwitnessed authority advance. + if let Some(runtime) = state.protected_transport() { + let enforcing = runtime.enforcing_domains(); + if !enforcing.is_empty() { + let hosts = state + .db + .usage_community_hosts() + .await? + .into_iter() + .map(|record| (buzz_core::CommunityId::from_uuid(record.id), record.host)) + .collect::>(); + for community_id in enforcing { + let host = hosts.get(&community_id).ok_or_else(|| { + anyhow::anyhow!( + "protected object verification domain has no active community mapping" + ) + })?; + let tenant = buzz_core::TenantContext::resolved(community_id, host); + let verification = async { + buzz_relay::api::git::migration::require_reconciled_authority(&state, &tenant) + .await?; + buzz_relay::api::media_migration::require_reconciled_authority(&state, &tenant) + .await?; + anyhow::Ok(()) + }; + tokio::time::timeout(std::time::Duration::from_secs(600), verification) + .await + .map_err(|_| anyhow::anyhow!("protected object verification timed out"))??; + } + } + } + // NIP-43: reconcile the event-backed roster for every provisioned // community before opening the listener. `relay_members` is canonical; // this repairs pre-snapshot communities and any publication that failed @@ -616,8 +661,12 @@ async fn main() -> anyhow::Result<()> { }); } - // Wire the action sink — must happen after AppState (which creates - // sub_registry, conn_manager) and before the cron loop starts. + // Wire the provider-neutral mutation gate and action sink after AppState + // construction and before any scheduled workflow can start. + let mutation_gate = Arc::new(buzz_relay::workflow_sink::RelayWorkflowMutationGate::new( + &state, + )); + workflow_engine.set_mutation_gate(mutation_gate); let action_sink = Arc::new(buzz_relay::workflow_sink::RelayActionSink::new(&state)); workflow_engine.set_action_sink(action_sink); @@ -645,7 +694,12 @@ async fn main() -> anyhow::Result<()> { loop { tokio::time::sleep(std::time::Duration::from_secs(reaper_interval_secs)).await; - let expired = match reaper_state.db.reap_expired_ephemeral_channels().await { + let excluded = reaper_state.enforcing_protected_domain_ids(); + let expired = match reaper_state + .db + .reap_expired_ephemeral_channels_excluding(&excluded) + .await + { Ok(ids) => ids, Err(e) => { error!("Ephemeral reaper tick failed: {e}"); @@ -748,9 +802,10 @@ async fn main() -> anyhow::Result<()> { tokio::time::sleep(std::time::Duration::from_secs(scheduler_interval_secs)).await; let now_secs = chrono::Utc::now().timestamp(); + let excluded = scheduler_state.enforcing_protected_domain_ids(); let due = match scheduler_state .db - .query_due_reminders(now_secs, scheduler_batch_limit) + .query_due_reminders_excluding(now_secs, scheduler_batch_limit, &excluded) .await { Ok(reminders) => reminders, @@ -1506,9 +1561,10 @@ async fn run_usage_metrics_tick( return Err(error); } let invite_retention_cutoff = chrono::Utc::now() - chrono::Duration::days(30); + let excluded = state.enforcing_protected_domain_ids(); match state .db - .reap_expired_relay_invites(invite_retention_cutoff) + .reap_expired_relay_invites_excluding(invite_retention_cutoff, &excluded) .await { Ok(deleted) if deleted > 0 => { diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 2ad3ce5fa7..d6c2242b70 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -157,6 +157,9 @@ pub struct MeshHandle { /// /// [`MeshAudioRouter`]: crate::audio::mesh::MeshAudioRouter pub audio_fence: Arc, + /// Live reliable-control attachments accepted by the realtime media lane. + /// Datagrams cannot create entries in this registry. + pub audio_attachments: Arc, /// The running mesh (status snapshots, shutdown). runtime: MeshRuntime, /// Per-room huddle owner-lease coordination. Shared with the WS-join owner @@ -180,6 +183,8 @@ impl MeshHandle { pub fn wire_consumers( &self, rooms: Arc, + db: buzz_db::Db, + relay_secret: &[u8], demo_echo: bool, shutting_down: Arc, ) { @@ -189,8 +194,15 @@ 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), + Some( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::new( + db, + relay_secret, + ), + ), demo_echo, shutting_down, ) @@ -221,14 +233,16 @@ impl MeshHandle { /// [`HuddleControlAcceptor::accept_inbound`]: crate::audio::join::HuddleControlAcceptor::accept_inbound /// [`ReliableJoin::Owned`]: crate::tunnel::reliable::ReliableJoin::Owned #[allow(clippy::too_many_arguments)] // boot-only parts bundle, one caller + tests -pub fn wire_mesh_consumers( +pub(crate) fn wire_mesh_consumers( dispatcher: &MeshInboundDispatcher, directory: SessionDirectory, transport: Arc, local_runtime_id: RuntimeId, audio_fence: Arc, + audio_attachments: Arc, rooms: Arc, owners: Arc, + authority_verifier: Option, demo_echo: bool, shutting_down: Arc, ) { @@ -239,21 +253,29 @@ 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 // huddles. The acceptor validates structurally, then Redis-fences every // stateful frame in its control loop. - let acceptor = Arc::new(crate::audio::join::HuddleControlAcceptor::new( + let acceptor = crate::audio::join::HuddleControlAcceptor::new( rooms, Arc::clone(&transport), Arc::new(directory.clone()), local_runtime_id, Arc::clone(&owners), - )); + audio_attachments, + ); + let acceptor = if let Some(verifier) = authority_verifier { + acceptor.with_authority_verifier(verifier) + } else { + acceptor + }; + let acceptor = Arc::new(acceptor); dispatcher.register_huddle_control(Box::new(move |from, hello, stream| { let acceptor = Arc::clone(&acceptor); tokio::spawn(async move { @@ -486,6 +508,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 +551,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 +743,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,25 +753,30 @@ 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()), + None, false, Arc::new(AtomicBool::new(false)), ); 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/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())); + } +} diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index c1e62b33b6..d99b46adcd 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -1,6 +1,9 @@ //! NIP-11 relay information document. +use std::num::NonZeroU64; + use serde::{Deserialize, Serialize}; +use thiserror::Error; #[cfg(test)] use crate::config::DEFAULT_MAX_FRAME_BYTES; @@ -20,6 +23,143 @@ pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33 /// to be verifiable by clients. pub(crate) const NIP_RELAY_MEMBERSHIP: u32 = 43; +/// Provider-neutral NIP-FI assertion transport profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NipFiTransportProfile { + /// Assertions are injected only by an origin-isolated trusted proxy that + /// strips untrusted inbound copies of the configured assertion header. + TrustedProxy, + /// Assertions are attached by the client to the same protected HTTP + /// request as its NIP-98 proof. + ClientAttached, +} + +/// Provider-neutral NIP-FI enrollment mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NipFiEnrollmentMode { + /// First enrollment requires an assertion key attestation. + AttestedKey, + /// Binding creation requires a separate privileged transition. + Provisioned, + /// First valid use may create the binding under explicit TOFU policy. + Tofu, +} + +/// Provider-neutral NIP-FI discovery object. +/// +/// Construction makes the delegation bound invariant unrepresentable: +/// delegation is `true` exactly when a positive finite maximum is present. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NipFiDiscovery { + transports: Vec, + enrollment: NipFiEnrollmentMode, + delegation: bool, + #[serde(skip_serializing_if = "Option::is_none")] + delegated_lease_max_seconds: Option, +} + +impl NipFiDiscovery { + /// Validate provider-neutral discovery configuration. + pub fn new( + mut transports: Vec, + enrollment: NipFiEnrollmentMode, + delegated_lease_max_seconds: Option, + ) -> Result { + if transports.is_empty() { + return Err(NipFiDiscoveryError::NoTransport); + } + transports.sort_unstable(); + let original_len = transports.len(); + transports.dedup(); + if transports.len() != original_len { + return Err(NipFiDiscoveryError::DuplicateTransport); + } + + Ok(Self { + transports, + enrollment, + delegation: delegated_lease_max_seconds.is_some(), + delegated_lease_max_seconds, + }) + } + + fn includes_transport(&self, transport: NipFiTransportProfile) -> bool { + self.transports.contains(&transport) + } +} + +/// Complete-stack conformance input supplied by the release/conformance lane. +/// +/// A source may return `true` only when every applicable NIP-FI row passed +/// against the same reviewed implementation revision. Trusted-proxy support +/// additionally requires deployment evidence for origin isolation and inbound +/// header stripping; synthetic code tests alone are insufficient. +pub trait CompleteNipFiRuntimeConformance: Send + Sync { + /// Exact reviewed implementation revision used for every applicable row. + fn reviewed_implementation_revision(&self) -> &str; + + /// Whether every applicable row passed at the reviewed revision. + fn all_applicable_rows_passed_at_same_revision(&self) -> bool; + + /// Whether trusted-proxy deployment controls and negative tests passed. + fn trusted_proxy_deployment_evidence_passed(&self) -> bool; +} + +/// Discovery proven ready by an injected complete-stack conformance source. +/// +/// The reviewed revision and evidence are deliberately not serialized into +/// NIP-11. This wrapper has no public field or unchecked constructor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConformanceReadyNipFiDiscovery(NipFiDiscovery); + +impl ConformanceReadyNipFiDiscovery { + /// Gate discovery on complete same-revision runtime and deployment proof. + pub fn from_complete_stack( + discovery: NipFiDiscovery, + conformance: &dyn CompleteNipFiRuntimeConformance, + ) -> Result { + let revision = conformance.reviewed_implementation_revision(); + if revision.len() != 40 + || !revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(NipFiDiscoveryError::InvalidReviewedRevision); + } + if !conformance.all_applicable_rows_passed_at_same_revision() { + return Err(NipFiDiscoveryError::IncompleteConformance); + } + if discovery.includes_transport(NipFiTransportProfile::TrustedProxy) + && !conformance.trusted_proxy_deployment_evidence_passed() + { + return Err(NipFiDiscoveryError::MissingTrustedProxyEvidence); + } + Ok(Self(discovery)) + } +} + +/// Fail-closed NIP-FI discovery construction error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum NipFiDiscoveryError { + /// At least one supported transport must be advertised. + #[error("NIP-FI discovery requires at least one transport")] + NoTransport, + /// Each supported transport may appear only once. + #[error("NIP-FI discovery contains a duplicate transport")] + DuplicateTransport, + /// The complete-stack report did not identify one exact Git revision. + #[error("NIP-FI conformance report has an invalid reviewed revision")] + InvalidReviewedRevision, + /// Not every applicable row passed at the same revision. + #[error("NIP-FI complete-stack conformance is incomplete")] + IncompleteConformance, + /// Trusted-proxy origin isolation and header stripping were not proven. + #[error("NIP-FI trusted-proxy deployment evidence is incomplete")] + MissingTrustedProxyEvidence, +} + /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayInfo { @@ -55,6 +195,10 @@ pub struct RelayInfo { /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, + /// Provider-neutral NIP-FI capabilities. Omitted until a complete-stack + /// same-revision conformance input explicitly enables discovery. + #[serde(skip_serializing_if = "Option::is_none")] + federated_identity: Option, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -85,6 +229,9 @@ pub struct RelayLimitation { /// NIP-ER: maximum allowed `not_before` horizon in seconds from now. #[serde(skip_serializing_if = "Option::is_none")] pub max_not_before_delta: Option, + /// NIP-FI support. Omitted until complete-stack conformance is proven. + #[serde(skip_serializing_if = "Option::is_none")] + federated_identity: Option, } /// Canonical `RelayLimitation` advertised by this relay. @@ -116,6 +263,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { restricted_writes: true, due_delivery_mode: Some("push".to_string()), max_not_before_delta: Some(max_not_before_delta), + federated_identity: None, } } @@ -169,8 +317,25 @@ impl RelayInfo { limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), relay_self: relay_self.map(|s| s.to_string()), + federated_identity: None, } } + + /// Add provider-neutral NIP-FI discovery after complete-stack proof. + /// + /// There is intentionally no raw boolean/configuration overload. The + /// normal runtime build path has no readiness input and therefore remains + /// silent until the release/conformance lane supplies this gated value. + pub fn with_conformant_federated_identity( + mut self, + ready: ConformanceReadyNipFiDiscovery, + ) -> Self { + if let Some(limitation) = &mut self.limitation { + limitation.federated_identity = Some(true); + } + self.federated_identity = Some(ready.0); + self + } } /// Axum handler that returns the NIP-11 relay information document as JSON. @@ -267,6 +432,9 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st .push("nip-pl".to_string()); info.push = Some(push); } + if let Some(ready) = state.nip_fi_discovery().cloned() { + info = info.with_conformant_federated_identity(ready); + } info } @@ -350,6 +518,34 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( mod tests { use super::*; + struct SyntheticConformance { + revision: &'static str, + complete: bool, + trusted_proxy_evidence: bool, + } + + impl CompleteNipFiRuntimeConformance for SyntheticConformance { + fn reviewed_implementation_revision(&self) -> &str { + self.revision + } + + fn all_applicable_rows_passed_at_same_revision(&self) -> bool { + self.complete + } + + fn trusted_proxy_deployment_evidence_passed(&self) -> bool { + self.trusted_proxy_evidence + } + } + + fn complete_conformance() -> SyntheticConformance { + SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: true, + trusted_proxy_evidence: true, + } + } + #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); @@ -402,6 +598,142 @@ mod tests { assert_eq!(info.software, "https://github.com/block/buzz"); } + #[test] + fn default_discovery_is_silent_until_complete_stack_input_exists() { + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let json = serde_json::to_value(info).expect("serialize default NIP-11"); + + assert!(json.get("federated_identity").is_none()); + assert!(json["limitation"].get("federated_identity").is_none()); + } + + #[test] + fn discovery_rejects_empty_duplicate_and_incomplete_inputs() { + assert_eq!( + NipFiDiscovery::new(Vec::new(), NipFiEnrollmentMode::AttestedKey, None), + Err(NipFiDiscoveryError::NoTransport) + ); + assert_eq!( + NipFiDiscovery::new( + vec![ + NipFiTransportProfile::ClientAttached, + NipFiTransportProfile::ClientAttached, + ], + NipFiEnrollmentMode::Provisioned, + None, + ), + Err(NipFiDiscoveryError::DuplicateTransport) + ); + + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::ClientAttached], + NipFiEnrollmentMode::Provisioned, + None, + ) + .expect("synthetic discovery is valid"); + for conformance in [ + SyntheticConformance { + revision: "not-a-revision", + complete: true, + trusted_proxy_evidence: true, + }, + SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: false, + trusted_proxy_evidence: true, + }, + ] { + assert!(ConformanceReadyNipFiDiscovery::from_complete_stack( + discovery.clone(), + &conformance, + ) + .is_err()); + } + } + + #[test] + fn trusted_proxy_advertisement_requires_deployment_evidence() { + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::TrustedProxy], + NipFiEnrollmentMode::AttestedKey, + None, + ) + .expect("synthetic discovery is valid"); + let conformance = SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: true, + trusted_proxy_evidence: false, + }; + + assert_eq!( + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &conformance), + Err(NipFiDiscoveryError::MissingTrustedProxyEvidence) + ); + } + + #[test] + fn conformant_discovery_is_provider_neutral_and_delegation_bounded() { + let max = NonZeroU64::new(300).expect("synthetic bound is positive"); + let discovery = NipFiDiscovery::new( + vec![ + NipFiTransportProfile::TrustedProxy, + NipFiTransportProfile::ClientAttached, + ], + NipFiEnrollmentMode::AttestedKey, + Some(max), + ) + .expect("synthetic discovery is valid"); + let ready = + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &complete_conformance()) + .expect("complete synthetic report enables discovery"); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None) + .with_conformant_federated_identity(ready); + let json = serde_json::to_value(info).expect("serialize conformant discovery"); + + assert_eq!(json["limitation"]["federated_identity"], true); + assert_eq!( + json["federated_identity"], + serde_json::json!({ + "transports": ["trusted-proxy", "client-attached"], + "enrollment": "attested-key", + "delegation": true, + "delegated_lease_max_seconds": 300, + }) + ); + let encoded = json.to_string(); + for private in [ + "synthetic-issuer", + "synthetic-subject", + "tenant.example", + "private-audience", + "assertion-header-name", + ] { + assert!(!encoded.contains(private)); + } + } + + #[test] + fn discovery_without_delegation_omits_lease_bound() { + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::ClientAttached], + NipFiEnrollmentMode::Tofu, + None, + ) + .expect("synthetic discovery is valid"); + let ready = + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &complete_conformance()) + .expect("complete synthetic report enables discovery"); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None) + .with_conformant_federated_identity(ready); + let json = serde_json::to_value(info).expect("serialize conformant discovery"); + + assert_eq!(json["federated_identity"]["delegation"], false); + assert!(json["federated_identity"] + .get("delegated_lease_max_seconds") + .is_none()); + assert_eq!(json["supported_nips"], serde_json::json!(SUPPORTED_NIPS)); + } + #[test] fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() { let info = RelayInfo::build( diff --git a/crates/buzz-relay/src/protected_surface.rs b/crates/buzz-relay/src/protected_surface.rs new file mode 100644 index 0000000000..4223d934e5 --- /dev/null +++ b/crates/buzz-relay/src/protected_surface.rs @@ -0,0 +1,1780 @@ +//! Provider-neutral inventory of relay authorization surfaces. +//! +//! This is the single reviewable registry for HTTP routes and long-lived +//! protocol operations. It records both protected operations and deliberate +//! exemptions. Runtime code derives the requested portable capability from +//! this module; request data never selects a provider profile or policy. + +use axum::http::Method; +use buzz_auth::{AuthTransport, AuthorizationCapability}; + +use crate::authorization_runtime::finalization::AuthorizationMode; + +/// Closed identifier for every backend-visible effect family. +/// +/// Adding an effect requires adding a registry row and choosing an explicit +/// Enforce disposition. Dynamic helper names cannot manufacture a permit. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectSurfaceId { + /// Durable Nostr event storage. + EventPersistence, + /// Channel, member, profile, reaction, moderation, or deletion projection. + EventDomainProjection, + /// Invitation creation. + InviteMint, + /// Invitation consumption and final membership creation. + InviteClaim, + /// PostgreSQL-authoritative media visibility. + MediaPublication, + /// PostgreSQL-authoritative Git ref visibility. + GitPublication, + /// Existing-member audio session admission. + AudioAdmission, + /// Legacy automatic audio membership creation. + AudioAutomaticMembership, + /// Durable audio lifecycle event persistence. + AudioLifecyclePersistence, + /// Automatic last-participant channel archival. + AudioAutomaticArchive, + /// Interactive workflow definition or state mutation. + WorkflowStateMutation, + /// Autonomous or delayed workflow execution. + WorkflowBackgroundExecution, + /// Arbitrary outbound HTTP webhook. + OutboundWebhook, + /// Any helper that has not been assigned a closed effect identifier. + UnclassifiedHelper, + /// Recipient-fenced local WebSocket delivery. + LocalFanout, + /// Recipient-fenced Redis delivery hint. + RedisFanout, + /// Redis-backed presence visible only through retained authority. + ProtectedPresence, + /// Persistent relay-signed helper event. + RelaySignedHelperEvent, + /// External push delivery. + PushDelivery, + /// Legacy best-effort audit-channel delivery; O5 owns durable audit. + LegacyAuditDelivery, + /// Cache eviction or connection cancellation derived from a commit. + CacheAndConnectionInvalidation, + /// Repairable legacy media sidecar written after authoritative publication. + MediaLegacySidecar, + /// Legacy moderation upload record emitted by object-store creation. + MediaUploadRecord, + /// Repairable legacy Git pointer written after authoritative publication. + GitLegacyPointer, + /// Durable invalidation polling and reconciliation. + AuthorizationReconciliation, + /// Durable public-assertion retirement derived from committed identity lifecycle state. + PublicProjectionRetirement, + /// Retryable local and cross-replica delivery of a committed public retirement. + PublicProjectionRetirementDelivery, + /// Dedicated exact-connection delivery of current binding status or withdrawal. + ClientStatusDelivery, + /// Storage garbage collection. + StorageGarbageCollection, + /// Reminder claim or publication. + ReminderWorker, + /// Push matching or delivery worker. + PushWorker, + /// Partition, expiry, or retention maintenance. + DatabaseMaintenance, + /// Audio mesh ownership maintenance. + AudioMeshMaintenance, + /// Metrics-only observation. + MetricsObservation, +} + +/// Effect relationship to protected business state. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectClass { + /// The effect is the durable source of protected state or visibility. + AuthoritativeMutation, + /// The effect is derived from an already-committed authoritative result. + DerivedDelivery, + /// The effect is server-owned maintenance rather than user authority. + SystemMaintenance, +} + +/// Code location category used by inventory coverage checks. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectOrigin { + /// HTTP route. + Http, + /// WebSocket operation. + WebSocket, + /// Shared helper invoked from more than one route. + Helper, + /// Autonomous or delayed background task. + Background, +} + +/// Why an effect is deliberately unavailable in Enforce. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum UnavailableReason { + /// No transaction-owned authorization permit is retained. + MissingTransactionAuthority, + /// No reviewed autonomous/system authority model exists. + MissingBackgroundAuthority, + /// The target cannot provide an authoritative idempotent commit boundary. + MissingExternalCommitPrimitive, + /// The legacy behavior would create membership implicitly. + AutomaticMembershipForbidden, + /// The effect has not been classified and registered. + UnclassifiedEffect, +} + +/// Enforce behavior selected for a registered effect. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EnforceDisposition { + /// The effect has the required authoritative or release primitive. + Supported, + /// Deny synchronously before the effect begins. + DenyBeforeEffect(UnavailableReason), +} + +/// One machine-readable effect registry row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EffectSurface { + /// Closed effect identifier. + pub id: EffectSurfaceId, + /// Stable provider-neutral inventory label. + pub name: &'static str, + /// Relationship to protected state. + pub class: EffectClass, + /// Code location category. + pub origin: EffectOrigin, + /// Portable capability, when the effect acts for a user operation. + pub capability: Option, + /// Enforce behavior. + pub enforce: EnforceDisposition, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +/// Enforce implementation selected for one authenticated EVENT kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventMutationDisposition { + /// The event and thread metadata use the common PostgreSQL executor. + TransactionalPersistence, + /// The kind requires a projection that has no transaction-aware adapter. + UnavailableProjection, + /// The kind enters a command/workflow executor without a shared commit. + UnavailableCommandOrWorkflow, +} + +/// Classify every EVENT kind before any Enforce mutation begins. +/// +/// Unknown kinds still fail in the ingest allowlist. This function only +/// chooses the commit primitive for kinds that pass normal protocol checks. +pub fn event_mutation_disposition(kind: u32) -> EventMutationDisposition { + use buzz_core::kind::*; + + if buzz_core::kind::is_moderation_command_kind(kind) + || matches!(kind, KIND_REPORT | KIND_GIT_REPO_ANNOUNCEMENT) + { + return EventMutationDisposition::TransactionalPersistence; + } + if matches!( + kind, + KIND_WORKFLOW_DEF + | KIND_WORKFLOW_TRIGGER + | KIND_APPROVAL_GRANT + | KIND_APPROVAL_DENY + | KIND_PUSH_LEASE + ) { + return EventMutationDisposition::UnavailableCommandOrWorkflow; + } + if matches!( + kind, + KIND_DM_OPEN + | KIND_DM_ADD_MEMBER + | KIND_DM_HIDE + | KIND_PRODUCT_FEEDBACK + | KIND_NIP43_LEAVE_REQUEST + | KIND_NIP29_CREATE_INVITE + ) || buzz_core::kind::is_relay_admin_kind(kind) + || buzz_core::kind::is_identity_archive_request_kind(kind) + { + return EventMutationDisposition::TransactionalPersistence; + } + if matches!( + kind, + 9003..=9004 + | 9006 + | 9010..=9020 + | 41001..=41003 + | 40099 + ) { + return EventMutationDisposition::UnavailableProjection; + } + EventMutationDisposition::TransactionalPersistence +} + +/// Recheck the stable handler surface, proof transport, and selected portable +/// capability before a resolver can observe the request. +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, + } +} + +/// Why a registered surface is deliberately outside tenant authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum SurfaceExemption { + /// Public relay metadata (NIP-05 and NIP-11-adjacent information). + PublicMetadata, + /// Kubernetes or service health endpoint. + HealthProbe, + /// Public pre-membership policy bootstrap. + JoinBootstrap, + /// Deployment-global operator authentication. + OperatorAuth, + /// Deployment-admin host/session authentication. + AdminAuth, + /// Loopback-only, HMAC-authenticated Git hook callback. + LocalHookCallback, + /// Disabled-by-default mesh testbed endpoint. + TestbedOnly, + /// Static UI fallback that cannot reach an API handler. + StaticUiFallback, +} + +/// How a registered surface participates in protected authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SurfaceProtection { + /// One fixed portable capability is required for the request. + Capability(AuthorizationCapability), + /// The request body or protocol operation determines the capability. + DynamicCapability, + /// A fixed capability committed through an authoritative transaction/CAS. + AtomicMutation(AuthorizationCapability), + /// A dynamically selected mutation committed through an authoritative executor. + DynamicAtomicMutation, + /// A fixed capability whose Enforce path remains unavailable until a + /// backend-specific transaction/CAS executor owns the complete commit. + AtomicMutationUnavailable(AuthorizationCapability), + /// A dynamically selected mutation capability with the same fail-closed + /// backend-executor requirement. + DynamicAtomicMutationUnavailable, + /// Authentication is completed after the HTTP upgrade. + Session { + /// Fixed capability for the session, or `None` for per-operation WS + /// authorization after NIP-42 AUTH. + capability: Option, + }, + /// A long-lived session whose admission mutates protected state and is + /// therefore unavailable in Enforce without an atomic backend executor. + AtomicMutationSessionUnavailable { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// A non-persistent session admitted under a bounded lease and cancellation fence. + LeasedSession { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// Plain GET/HEAD is public metadata; a WebSocket upgrade enters protected + /// per-operation session authorization. + ConditionalWebSocketUpgrade, + /// Protection depends on the server-owned media-read setting. + ConditionalMediaRead(AuthorizationCapability), + /// Deliberately outside tenant protected authorization. + Exempt(SurfaceExemption), +} + +impl SurfaceProtection { + /// Stable low-cardinality label for tracing and inventory exports. + pub const fn trace_label(self) -> &'static str { + match self { + Self::Capability(_) => "required", + Self::DynamicCapability => "required_dynamic_capability", + Self::AtomicMutation(_) => "required_atomic_commit", + Self::DynamicAtomicMutation => "required_dynamic_atomic_commit", + Self::AtomicMutationUnavailable(_) => "enforce_unavailable_without_atomic_executor", + Self::DynamicAtomicMutationUnavailable => { + "enforce_unavailable_without_dynamic_atomic_executor" + } + Self::Session { .. } => "required_at_session_auth", + Self::AtomicMutationSessionUnavailable { .. } => { + "enforce_session_unavailable_without_atomic_executor" + } + Self::LeasedSession { .. } => "required_leased_session", + Self::ConditionalWebSocketUpgrade => "required_on_websocket_upgrade", + Self::ConditionalMediaRead(_) => "required_when_media_reads_protected", + Self::Exempt(SurfaceExemption::PublicMetadata) => "exempt_public_metadata", + Self::Exempt(SurfaceExemption::HealthProbe) => "exempt_health_probe", + Self::Exempt(SurfaceExemption::JoinBootstrap) => "exempt_join_bootstrap", + Self::Exempt(SurfaceExemption::OperatorAuth) => "exempt_operator_auth", + Self::Exempt(SurfaceExemption::AdminAuth) => "exempt_admin_auth", + Self::Exempt(SurfaceExemption::LocalHookCallback) => "exempt_local_hook_callback", + Self::Exempt(SurfaceExemption::TestbedOnly) => "exempt_testbed_only", + Self::Exempt(SurfaceExemption::StaticUiFallback) => "exempt_static_ui_fallback", + } + } +} + +/// Required lifetime checkpoints for a protected operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum GuardPoint { + /// Validate before handler work begins. + Request, + /// Revalidate before a durable or externally visible mutation commits. + PreCommit, + /// Revalidate after asynchronous fetches and before buffered output is released. + PreEmission, + /// Revalidate before each streamed chunk or live event emission. + StreamEmission, + /// Renew or close a long-lived session before its lease expires. + SessionRenewal, +} + +const REQUEST: &[GuardPoint] = &[GuardPoint::Request]; +const REQUEST_COMMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreCommit]; +const REQUEST_EMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreEmission]; +const REQUEST_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreEmission, + GuardPoint::StreamEmission, +]; +const SESSION_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; +const SESSION_COMMIT_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreCommit, + GuardPoint::PreEmission, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; + +const fn effect( + id: EffectSurfaceId, + name: &'static str, + class: EffectClass, + origin: EffectOrigin, + capability: Option, + enforce: EnforceDisposition, + guard_points: &'static [GuardPoint], +) -> EffectSurface { + EffectSurface { + id, + name, + class, + origin, + capability, + enforce, + guard_points, + } +} + +/// Exhaustive provider-neutral inventory of backend-visible effect families. +pub const EFFECT_SURFACES: &[EffectSurface] = &[ + effect( + EffectSurfaceId::EventPersistence, + "event.persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::EventDomainProjection, + "event.domain_projection", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteMint, + "invite.mint_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteMint), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteClaim, + "invite.claim_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteClaim), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::MediaPublication, + "media.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::MediaWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::GitPublication, + "git.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::GitWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAdmission, + "audio.existing_member_admission", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::Supported, + SESSION_COMMIT_STREAM, + ), + effect( + EffectSurfaceId::AudioAutomaticMembership, + "audio.automatic_membership", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::AutomaticMembershipForbidden), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioLifecyclePersistence, + "audio.lifecycle_persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAutomaticArchive, + "audio.automatic_archive", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowStateMutation, + "workflow.interactive_state", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowBackgroundExecution, + "workflow.background_execution", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::OutboundWebhook, + "workflow.outbound_webhook", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::UnclassifiedHelper, + "background.unclassified_helper", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::UnclassifiedEffect), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LocalFanout, + "delivery.local_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RedisFanout, + "delivery.redis_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ProtectedPresence, + "delivery.protected_presence", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RelaySignedHelperEvent, + "delivery.relay_signed_helper_event", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::PushDelivery, + "delivery.external_push", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LegacyAuditDelivery, + "delivery.legacy_audit_channel", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::CacheAndConnectionInvalidation, + "delivery.cache_connection_invalidation", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaLegacySidecar, + "delivery.media_legacy_sidecar", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaUploadRecord, + "delivery.media_upload_record", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::GitLegacyPointer, + "delivery.git_legacy_pointer", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::AuthorizationReconciliation, + "maintenance.authorization_reconciliation", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::PublicProjectionRetirement, + "maintenance.public_projection_retirement", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PublicProjectionRetirementDelivery, + "delivery.public_projection_retirement", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ClientStatusDelivery, + "client.status.dedicated_delivery", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::StorageGarbageCollection, + "maintenance.storage_gc", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::ReminderWorker, + "maintenance.reminder_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PushWorker, + "maintenance.push_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::DatabaseMaintenance, + "maintenance.database", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioMeshMaintenance, + "maintenance.audio_mesh", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::MetricsObservation, + "maintenance.metrics_observation", + EffectClass::SystemMaintenance, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST, + ), +]; + +/// Unforgeable proof that one registered effect is permitted in the selected mode. +pub struct EffectPermit { + id: EffectSurfaceId, +} + +impl EffectPermit { + /// Registered effect represented by this permit. + pub const fn id(&self) -> EffectSurfaceId { + self.id + } +} + +/// Fail-closed effect classification error. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum EffectPermitError { + /// The closed identifier has no registry row. + #[error("protected effect is not classified")] + Unclassified, + /// The registered effect is deliberately unavailable in protected mode. + #[error("protected effect is unavailable in protected mode")] + Unavailable(UnavailableReason), +} + +/// Require a registered pre-effect permit for one exact activation mode. +pub fn require_effect_permit( + mode: Option, + id: EffectSurfaceId, +) -> Result { + let surface = EFFECT_SURFACES + .iter() + .find(|surface| surface.id == id) + .ok_or(EffectPermitError::Unclassified)?; + if mode.is_some_and(AuthorizationMode::protects_surfaces) { + if let EnforceDisposition::DenyBeforeEffect(reason) = surface.enforce { + return Err(EffectPermitError::Unavailable(reason)); + } + } + Ok(EffectPermit { id }) +} + +/// Resolve only a registered stable helper name; unknown names never fall back. +pub fn effect_surface_by_name(name: &str) -> Option<&'static EffectSurface> { + EFFECT_SURFACES.iter().find(|surface| surface.name == name) +} + +/// Require a registered stable effect name. Unknown helper names preserve +/// legacy modes but map to the explicit unclassified-denial row in Enforce. +pub fn require_effect_name( + mode: Option, + name: &str, +) -> Result { + let id = effect_surface_by_name(name) + .map_or(EffectSurfaceId::UnclassifiedHelper, |surface| surface.id); + require_effect_permit(mode, id) +} + +/// One registered HTTP route and its protected-authorization contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpSurface { + /// HTTP method as an uppercase token. + pub method: &'static str, + /// Axum matched-path template, never a literal untrusted path. + pub matched_path: &'static str, + /// Authorization classification. + pub protection: SurfaceProtection, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +const fn http( + method: &'static str, + matched_path: &'static str, + protection: SurfaceProtection, + guard_points: &'static [GuardPoint], +) -> HttpSurface { + HttpSurface { + method, + matched_path, + protection, + guard_points, + } +} + +const fn exempt(reason: SurfaceExemption) -> SurfaceProtection { + SurfaceProtection::Exempt(reason) +} + +/// Exhaustive inventory of API routes registered by the relay router. +/// +/// Static UI fallbacks are recorded separately in [`NON_ROUTER_SURFACES`] +/// because they have no Axum `MatchedPath` and cannot reach an API handler. +pub const HTTP_SURFACES: &[HttpSurface] = &[ + http( + "GET", + "/", + SurfaceProtection::ConditionalWebSocketUpgrade, + SESSION_STREAM, + ), + http( + "HEAD", + "/", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/info", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/.well-known/nostr.json", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/health", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_liveness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_readiness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_status", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_mesh", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "POST", + "/events", + SurfaceProtection::DynamicAtomicMutation, + REQUEST_COMMIT, + ), + http( + "POST", + "/query", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "POST", + "/count", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "GET", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/archive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/unarchive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "GET", + "/operator/communities/availability", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/transfer", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/api/invites", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteMint), + REQUEST_COMMIT, + ), + http( + "POST", + "/api/invites/claim", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteClaim), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/join-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/terms", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/privacy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "POST", + "/api/invites/accept-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/moderation/reports", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/audit", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/restricted", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "POST", + "/hooks/{id}", + SurfaceProtection::AtomicMutationUnavailable(AuthorizationCapability::CommunityWrite), + REQUEST_COMMIT, + ), + http( + "POST", + "/_mesh/demo/echo", + exempt(SurfaceExemption::TestbedOnly), + REQUEST, + ), + http( + "POST", + "/internal/git/policy", + exempt(SurfaceExemption::LocalHookCallback), + REQUEST, + ), + http( + "GET", + "/huddle/{channel_id}/audio", + SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin, + }, + SESSION_COMMIT_STREAM, + ), + http( + "PUT", + "/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "PUT", + "/media/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_STREAM, + ), + http( + "HEAD", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_EMIT, + ), + http( + "GET", + "/git/{owner}/{repo}/info/refs", + SurfaceProtection::DynamicCapability, + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-upload-pack", + SurfaceProtection::Capability(AuthorizationCapability::GitRead), + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-receive-pack", + SurfaceProtection::AtomicMutation(AuthorizationCapability::GitWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/admin/v1/reports", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/reports/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}/attachments/{sha256}", + exempt(SurfaceExemption::AdminAuth), + REQUEST_STREAM, + ), +]; + +/// Surface outside Axum's matched-route inventory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NonRouterSurface { + /// Stable surface name. + pub name: &'static str, + /// Explicit protection or exemption. + pub protection: SurfaceProtection, +} + +/// Explicit inventory for request fallbacks and other non-router surfaces. +pub const NON_ROUTER_SURFACES: &[NonRouterSurface] = &[NonRouterSurface { + name: "static_ui_fallback", + protection: SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback), +}]; + +/// Classify a registered method and trusted Axum matched-path template. +pub fn classify_http(method: &Method, matched_path: &str) -> Option<&'static HttpSurface> { + let exact = HTTP_SURFACES + .iter() + .find(|surface| surface.method == method.as_str() && surface.matched_path == matched_path); + if exact.is_some() || method != Method::HEAD { + return exact; + } + HTTP_SURFACES + .iter() + .find(|surface| surface.method == "GET" && surface.matched_path == matched_path) +} + +/// Whether any registered method names the matched template. +pub fn is_known_http_path(matched_path: &str) -> bool { + HTTP_SURFACES + .iter() + .any(|surface| surface.matched_path == matched_path) +} + +/// RFC 9110 `Allow` value for a known matched template. +pub fn allowed_http_methods(matched_path: &str) -> Option { + let mut methods = Vec::new(); + for surface in HTTP_SURFACES + .iter() + .filter(|surface| surface.matched_path == matched_path) + { + if !methods.contains(&surface.method) { + methods.push(surface.method); + } + if surface.method == "GET" && !methods.contains(&"HEAD") { + methods.push("HEAD"); + } + } + (!methods.is_empty()).then(|| methods.join(", ")) +} + +/// Protected WebSocket operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum WebSocketOperation { + /// NIP-42 session bootstrap. It establishes identity but grants no data-plane capability. + Auth, + /// Historical or live subscription creation. + Req, + /// Aggregate query. + Count, + /// Persistent or ephemeral event ingest. + Event { + /// Nostr event kind used to select write or moderation authority. + kind: u32, + }, + /// Live event delivery to a subscription. + Fanout, +} + +/// One protocol-level WebSocket operation and its release/commit contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebSocketSurface { + /// Stable low-cardinality operation label. + pub operation: &'static str, + /// Operation-level authorization classification. + pub protection: SurfaceProtection, + /// Required checks from dispatch through commit or socket emission. + pub guard_points: &'static [GuardPoint], +} + +/// Operation-level inventory for the WebSocket route. +/// +/// The HTTP `/` row describes only upgrade/session lifetime. This table keeps +/// EVENT commit fencing distinct from REQ/COUNT/fanout release fencing. +pub const WEBSOCKET_SURFACES: &[WebSocketSurface] = &[ + WebSocketSurface { + operation: "AUTH", + protection: SurfaceProtection::Session { capability: None }, + guard_points: REQUEST, + }, + WebSocketSurface { + operation: "REQ", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, + WebSocketSurface { + operation: "COUNT", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_EMIT, + }, + WebSocketSurface { + operation: "EVENT", + protection: SurfaceProtection::DynamicAtomicMutation, + guard_points: REQUEST_COMMIT, + }, + WebSocketSurface { + operation: "fanout", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, +]; + +/// Return the exact capability for a WebSocket operation. +/// +/// AUTH deliberately returns `None`: verification or authentication alone must +/// never become data-plane authority. Every subsequent operation requests its +/// own capability through the same direct/delegated runtime path. +pub const fn websocket_capability( + operation: WebSocketOperation, +) -> Option { + match operation { + WebSocketOperation::Auth => None, + WebSocketOperation::Req | WebSocketOperation::Count | WebSocketOperation::Fanout => { + Some(AuthorizationCapability::CommunityRead) + } + WebSocketOperation::Event { kind: 9040..=9044 } => Some(AuthorizationCapability::Moderate), + WebSocketOperation::Event { .. } => Some(AuthorizationCapability::CommunityWrite), + } +} + +/// 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 server-validated service. +pub fn git_info_refs_capability(service: &str) -> Option { + match service { + "git-upload-pack" => Some(AuthorizationCapability::GitRead), + "git-receive-pack" => Some(AuthorizationCapability::GitWrite), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn effect_inventory_is_closed_unique_and_guarded() { + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + for surface in EFFECT_SURFACES { + assert!( + ids.insert(surface.id), + "duplicate effect id: {:?}", + surface.id + ); + assert!( + names.insert(surface.name), + "duplicate effect name: {}", + surface.name + ); + assert!(!surface.name.is_empty()); + assert!(!surface.guard_points.is_empty()); + if surface.class == EffectClass::AuthoritativeMutation + && surface.enforce == EnforceDisposition::Supported + && surface.id != EffectSurfaceId::AudioAdmission + { + assert!(surface.guard_points.contains(&GuardPoint::PreCommit)); + } + if surface.class == EffectClass::DerivedDelivery + && surface.enforce == EnforceDisposition::Supported + { + assert!(surface.guard_points.contains(&GuardPoint::PreEmission)); + } + assert_eq!(effect_surface_by_name(surface.name), Some(surface)); + } + } + + #[test] + fn event_effect_classification_separates_transactional_and_unavailable_paths() { + for kind in [ + buzz_core::kind::KIND_TEXT_NOTE, + buzz_core::kind::KIND_REACTION, + buzz_core::kind::KIND_DELETION, + buzz_core::kind::KIND_REPORT, + buzz_core::kind::KIND_LONG_FORM, + buzz_core::kind::KIND_MODERATION_BAN, + buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT, + buzz_core::kind::KIND_PROFILE, + buzz_core::kind::KIND_AGENT_PROFILE, + buzz_core::kind::KIND_PRODUCT_FEEDBACK, + buzz_core::kind::KIND_NIP43_LEAVE_REQUEST, + buzz_core::kind::KIND_IA_ARCHIVE_REQUEST, + buzz_core::kind::KIND_IA_UNARCHIVE_REQUEST, + buzz_core::kind::RELAY_ADMIN_ADD_MEMBER, + buzz_core::kind::RELAY_ADMIN_REMOVE_MEMBER, + buzz_core::kind::RELAY_ADMIN_CHANGE_ROLE, + buzz_core::kind::RELAY_ADMIN_SET_WORKSPACE_PROFILE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence, + "interactive kind {kind} must retain transaction-owned persistence" + ); + } + for kind in [ + buzz_core::kind::KIND_NIP29_PUT_USER, + buzz_core::kind::KIND_NIP29_REMOVE_USER, + buzz_core::kind::KIND_NIP29_EDIT_METADATA, + buzz_core::kind::KIND_NIP29_DELETE_EVENT, + buzz_core::kind::KIND_NIP29_CREATE_GROUP, + buzz_core::kind::KIND_NIP29_DELETE_GROUP, + buzz_core::kind::KIND_NIP29_JOIN_REQUEST, + buzz_core::kind::KIND_NIP29_LEAVE_REQUEST, + buzz_core::kind::KIND_NIP29_CREATE_INVITE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence + ); + } + assert_eq!( + event_mutation_disposition(buzz_core::kind::KIND_WORKFLOW_TRIGGER), + EventMutationDisposition::UnavailableCommandOrWorkflow + ); + } + + #[test] + fn enforce_denies_background_webhooks_and_automatic_membership_before_effect() { + for id in [ + EffectSurfaceId::WorkflowBackgroundExecution, + EffectSurfaceId::OutboundWebhook, + EffectSurfaceId::UnclassifiedHelper, + EffectSurfaceId::AudioAutomaticMembership, + EffectSurfaceId::ReminderWorker, + EffectSurfaceId::PushWorker, + EffectSurfaceId::LegacyAuditDelivery, + ] { + assert!(require_effect_permit(Some(AuthorizationMode::Enforce), id).is_err()); + assert!(require_effect_permit(Some(AuthorizationMode::Off), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::Shadow), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::VerifyOnly), id).is_ok()); + assert!(require_effect_permit(None, id).is_ok()); + } + } + + #[test] + fn unknown_helper_name_has_no_enforce_fallback() { + assert!(effect_surface_by_name("helper.added_without_classification").is_none()); + assert!(require_effect_name( + Some(AuthorizationMode::Enforce), + "helper.added_without_classification" + ) + .is_err()); + assert!(require_effect_name( + Some(AuthorizationMode::Off), + "helper.added_without_classification" + ) + .is_ok()); + } + + fn assert_ordered(source: &str, first: &str, second: &str) { + let first_index = source.find(first).expect("first boundary exists"); + let second_index = source.find(second).expect("second boundary exists"); + assert!(first_index < second_index, "{first} must precede {second}"); + } + + #[test] + fn inventory_keys_are_unique_and_every_row_has_guards() { + let mut seen = HashSet::new(); + for surface in HTTP_SURFACES { + assert!( + seen.insert((surface.method, surface.matched_path)), + "duplicate protected-surface row for {} {}", + surface.method, + surface.matched_path + ); + assert!(!surface.guard_points.is_empty()); + } + for surface in WEBSOCKET_SURFACES { + assert!(seen.insert(("WS", surface.operation))); + assert!(!surface.guard_points.is_empty()); + } + } + + #[test] + fn dynamic_operations_request_exact_capabilities() { + assert_eq!(websocket_capability(WebSocketOperation::Auth), None); + assert_eq!( + websocket_capability(WebSocketOperation::Req), + Some(AuthorizationCapability::CommunityRead) + ); + assert_eq!( + event_ingest_capability(9042), + AuthorizationCapability::Moderate + ); + assert_eq!( + event_ingest_capability(1), + AuthorizationCapability::CommunityWrite + ); + assert!(protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteClaim, + )); + assert!(!protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteMint, + )); + } + + #[test] + fn conditional_and_session_roots_are_explicit() { + assert!(matches!( + classify_http(&Method::GET, "/").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalWebSocketUpgrade) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::PublicMetadata)) + )); + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .map(|surface| surface.protection), + Some(SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin + }) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/media/{sha256_ext}").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalMediaRead( + AuthorizationCapability::MediaRead + )) + )); + } + + #[test] + fn exemptions_are_named_and_unknown_routes_fail_classification() { + assert!(matches!( + classify_http(&Method::GET, "/_readiness").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::HealthProbe)) + )); + assert!(classify_http(&Method::GET, "/unclassified").is_none()); + assert!(classify_http(&Method::GET, "/media/literal-sha").is_none()); + assert_eq!(NON_ROUTER_SURFACES.len(), 1); + assert!(matches!( + NON_ROUTER_SURFACES[0].protection, + SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback) + )); + } + + #[test] + fn git_advertisement_service_selects_read_or_write() { + assert_eq!( + git_info_refs_capability("git-upload-pack"), + Some(AuthorizationCapability::GitRead) + ); + assert_eq!( + git_info_refs_capability("git-receive-pack"), + Some(AuthorizationCapability::GitWrite) + ); + assert_eq!(git_info_refs_capability("unknown"), None); + } + + #[test] + fn every_mutating_route_declares_its_authoritative_or_unavailable_boundary() { + for (method, path) in [ + (Method::POST, "/events"), + (Method::POST, "/api/invites"), + (Method::POST, "/api/invites/claim"), + ] { + let protection = classify_http(&method, path) + .expect("mutating route is inventoried") + .protection; + assert!(matches!( + protection, + SurfaceProtection::AtomicMutation(_) | SurfaceProtection::DynamicAtomicMutation + )); + } + assert!(matches!( + classify_http(&Method::POST, "/hooks/{id}") + .expect("webhook is inventoried") + .protection, + SurfaceProtection::AtomicMutationUnavailable(_) + )); + for (method, path) in [ + (Method::PUT, "/upload"), + (Method::PUT, "/media/upload"), + (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + ] { + assert!(matches!( + classify_http(&method, path) + .expect("functional mutation is inventoried") + .protection, + SurfaceProtection::AtomicMutation(_) + )); + } + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .expect("audio is inventoried") + .protection, + SurfaceProtection::LeasedSession { .. } + )); + assert!(WEBSOCKET_SURFACES.iter().any(|surface| { + surface.operation == "EVENT" + && surface.protection == SurfaceProtection::DynamicAtomicMutation + && surface.guard_points.contains(&GuardPoint::PreCommit) + })); + } + + #[test] + fn enforce_mutation_gates_precede_every_shared_business_effect_boundary() { + let ingest = include_str!("handlers/ingest.rs"); + assert_ordered( + ingest, + "event_mutation_disposition", + "handle_moderation_command", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "insert_event_with_thread_metadata_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "apply_nip29_mutation_tx", + ); + let report_path = ingest + .split_once("if kind_u32 == KIND_REPORT") + .expect("report path exists") + .1; + assert_ordered( + report_path, + "handle_report_event_enforced", + "return Ok(IngestResult", + ); + let moderation_path = ingest + .split_once("if buzz_core::kind::is_moderation_command_kind(kind_u32)") + .expect("moderation path exists") + .1; + assert_ordered( + moderation_path, + "handle_moderation_command_enforced", + "return Ok(IngestResult", + ); + let feedback_path = ingest + .split_once("if kind_u32 == KIND_PRODUCT_FEEDBACK") + .expect("feedback path exists") + .1; + assert_ordered( + feedback_path, + "handle_enforced(tenant, &event, state, &protected)", + "emit_product_feedback_success", + ); + let relay_admin = include_str!("handlers/relay_admin.rs"); + let enforced_relay_admin = relay_admin + .split_once("handle_relay_admin_event_enforced") + .expect("protected relay-admin executor exists") + .1; + assert_ordered( + enforced_relay_admin, + "begin_authorized_operation", + "execute_relay_admin_command_tx", + ); + let identity_archive = include_str!("handlers/identity_archive.rs"); + let enforced_archive = identity_archive + .split_once("handle_identity_archive_event_tx") + .expect("protected archive transaction exists") + .1; + assert_ordered(enforced_archive, "determine_consent_path_tx", "archive_tx"); + let relay_leave = ingest + .split_once("if kind_u32 == KIND_NIP43_LEAVE_REQUEST") + .expect("relay leave path exists") + .1; + assert_ordered( + relay_leave, + "begin_authorized_operation", + "remove_relay_member_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "replace_protected_announcement_tx", + ); + let media = include_str!("api/media.rs"); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "if upload_rate_limited(", + ); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "acquire_upload_permit(state, community_id, pubkey)", + ); + let media_upload = media + .split_once("pub async fn upload_blob") + .expect("media upload handler exists") + .1; + assert_ordered( + media_upload, + "commit_media_publication(", + "Ok(Json(descriptor))", + ); + + let git = include_str!("api/git/transport.rs"); + let finalize_push = git + .split_once("async fn finalize_push") + .expect("Git push finalizer exists") + .1; + assert_ordered( + finalize_push, + "begin_authorized_operation(", + "compare_and_publish_git(", + ); + let after_commit = finalize_push + .split_once("operation.commit(&payload)") + .expect("PostgreSQL Git publication commits a receipt") + .1; + assert!(after_commit.contains("build_git_response(\"receive-pack\"")); + + let bridge = include_str!("api/bridge.rs"); + let webhook = bridge + .split_once("pub async fn workflow_webhook") + .expect("workflow webhook handler exists") + .1; + assert_ordered(webhook, "require_effect_permit(", ".get_workflow("); + assert_ordered(webhook, "require_effect_permit(", ".create_workflow_run("); + assert_ordered( + include_str!("workflow_sink.rs"), + "require_effect_permit(", + ".insert_event_with_thread_metadata(", + ); + + let workflow_engine = include_str!("../../buzz-workflow/src/lib.rs"); + let on_event = workflow_engine + .split_once("pub async fn on_event") + .expect("event workflow trigger exists") + .1; + assert_ordered(on_event, "self.require_mutation(", ".create_workflow_run("); + let scheduler = workflow_engine + .split_once("pub async fn run") + .expect("workflow scheduler exists") + .1; + assert_ordered( + scheduler, + "self.require_mutation(", + ".claim_scheduled_workflow_fire(", + ); + assert_ordered(scheduler, "self.require_mutation(", ".create_workflow_run("); + + let workflow_executor = include_str!("../../buzz-workflow/src/executor.rs"); + let action_dispatch = workflow_executor + .split_once("pub async fn dispatch_action") + .expect("workflow action dispatcher exists") + .1; + assert_ordered( + action_dispatch, + "engine.require_mutation(", + "add_reaction_impl(", + ); + assert_ordered( + action_dispatch, + "engine.require_outbound_webhook(", + "call_webhook_impl(", + ); + + let relay_main = include_str!("main.rs"); + assert_ordered(relay_main, "set_mutation_gate(", "wf_cron.run("); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "reap_expired_ephemeral_channels_excluding", + ); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "query_due_reminders_excluding", + ); + + let push = include_str!("push_runtime.rs"); + assert_ordered( + push, + "enforcing_protected_domain_ids()", + "claim_due_push_match_batch_excluding", + ); + assert_ordered(push, "is_protected_enforcing", "claim_due_push_wakes"); + + let websocket = include_str!("handlers/event.rs"); + let ephemeral = websocket + .split_once("async fn handle_ephemeral_event") + .expect("ephemeral event handler exists") + .1; + assert_ordered(ephemeral, "authority.revalidate()", ".publish_event("); + assert_ordered( + ephemeral, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(ephemeral.contains("fan_out_event_to_local_subscribers_with_authority")); + let observer = websocket + .split_once("async fn handle_agent_observer_event") + .expect("observer event handler exists") + .1; + assert_ordered(observer, "authority.revalidate()", ".publish_event("); + assert_ordered( + observer, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(observer.contains("fan_out_event_to_local_subscribers_with_authority")); + let presence = websocket + .split_once("if event_kind_u32(&event) == KIND_PRESENCE_UPDATE") + .expect("presence effect boundary exists") + .1; + assert_ordered(presence, "encode_presence(", ".set_presence("); + assert!(websocket.contains("send_to_text_bytes_guarded_pair")); + assert_ordered( + websocket, + "if legacy_audit_delivery_allowed", + "enqueue_event_created_audit(", + ); + let connection = include_str!("connection.rs"); + assert!(connection.contains("CombinedReleaseFence")); + assert!(connection.contains("self.sender.release().await")); + assert!(connection.contains("self.recipient.release().await")); + + let presence_read = bridge + .split_once("async fn synthesize_presence") + .expect("presence read boundary exists") + .1; + assert_ordered(presence_read, "decode_presence(", "verify_actor_context("); + assert_ordered( + presence_read, + "verify_actor_context(", + "presence_map.insert(", + ); + + let invites = include_str!("api/invites.rs"); + let mint = invites + .split_once("pub async fn mint_invite") + .expect("invite mint handler exists") + .1; + assert_ordered(mint, "begin_authorized_operation", "mint_relay_invite_tx"); + let claim = invites + .split_once("pub async fn claim_invite") + .expect("invite claim handler exists") + .1; + assert_ordered( + claim, + "begin_authorized_enrollment", + "claim_relay_invite_with_identity_tx", + ); + + assert_ordered( + include_str!("audio/handler.rs"), + "debug_assert!(!protected_authority.is_enforcing())", + ".add_member_with_identity(", + ); + + let corporate_identity = include_str!("corporate_identity.rs") + .split_once("async fn record_identity_binding_audit") + .expect("identity audit helper exists") + .1; + assert_ordered( + corporate_identity, + "require_effect_permit(", + "let Some(audit_tx)", + ); + + let media_audit = media + .split_once("// Audit via bounded channel") + .expect("media audit boundary exists") + .1; + assert_ordered(media_audit, "require_effect_permit(", "audit_tx"); + } +} diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 49845067ea..05835938f9 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -58,8 +58,13 @@ pub async fn run_matcher(state: Arc) { let mut idle_delay = IDLE_POLL_FLOOR; let mut last_reap = tokio::time::Instant::now(); loop { + let excluded = state.enforcing_protected_domain_ids(); if last_reap.elapsed() >= REAP_INTERVAL { - match state.db.reap_exhausted_push_matches().await { + match state + .db + .reap_exhausted_push_matches_excluding(&excluded) + .await + { Ok(reaped) if reaped > 0 => warn!(reaped, "reaped exhausted push match jobs"), Ok(_) => {} Err(e) => error!("push match reap failed: {e}"), @@ -69,7 +74,7 @@ pub async fn run_matcher(state: Arc) { let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); match state .db - .claim_due_push_match_batch(MATCH_BATCH_LIMIT, until) + .claim_due_push_match_batch_excluding(MATCH_BATCH_LIMIT, until, &excluded) .await { Ok(Some(batch)) => { @@ -321,6 +326,9 @@ pub async fn run_delivery_worker(state: Arc) { Ok(communities) => { for community in communities { let community = buzz_core::CommunityId::from_uuid(community.id); + if state.is_protected_enforcing(community) { + continue; + } let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); match state.db.claim_due_push_wakes(community, 16, until).await { Ok(wakes) => { @@ -351,6 +359,9 @@ async fn deliver_one( http: &reqwest::Client, claimed: buzz_db::push::ClaimedWake, ) { + if state.is_protected_enforcing(claimed.community) { + return; + } let outcome = match state .db .revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id) diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 7737604495..5c922b2e52 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -206,16 +206,20 @@ async fn enforce_corporate_identity_route_inventory( request: Request, next: Next, ) -> axum::response::Response { - enforce_route_inventory_for_requirement(state.config.corporate_identity.require, request, next) - .await + enforce_route_inventory_for_requirement( + state.config.corporate_identity.require || state.protected_transport().is_some(), + request, + next, + ) + .await } async fn enforce_route_inventory_for_requirement( - corporate_identity_required: bool, + protected_inventory_required: bool, request: Request, next: Next, ) -> axum::response::Response { - if !corporate_identity_required { + if !protected_inventory_required { return next.run(request).await; } let matched_path = request.extensions().get::(); @@ -240,11 +244,11 @@ async fn enforce_route_inventory_for_requirement( tracing::error!( method = %request.method(), matched_path = matched_path.map(|path| path.as_str()).unwrap_or(""), - "rejecting route missing corporate identity policy classification" + "rejecting route missing protected-surface policy classification" ); ( StatusCode::SERVICE_UNAVAILABLE, - "route unavailable: identity policy is not configured", + "route unavailable: protected-surface policy is not configured", ) .into_response() } @@ -374,10 +378,21 @@ async fn nip11_or_ws_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(), + format!("restricted: {}", error.public_message()), + ) + .into_response() + } + }; let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { @@ -393,7 +408,7 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection(socket, state, addr, tenant, corporate_identity_jwt) + handle_connection(socket, state, addr, tenant, corporate_identity_assertion) }) .into_response() } diff --git a/crates/buzz-relay/src/router/route_policy.rs b/crates/buzz-relay/src/router/route_policy.rs index 38859e10a9..37653953da 100644 --- a/crates/buzz-relay/src/router/route_policy.rs +++ b/crates/buzz-relay/src/router/route_policy.rs @@ -1,369 +1,33 @@ -//! Central inventory of corporate-identity policy at the HTTP routing boundary. -//! -//! This module classifies axum's *matched route template* (for example, -//! `/media/{sha256_ext}`), not an untrusted literal request path. Keeping the -//! complete inventory here makes every authenticated surface and every -//! deliberate exemption reviewable in one place. The handlers remain the -//! enforcement point because they have the authenticated principal, resolved -//! tenant, and admission result needed to finalize an identity safely. +//! Router adapter for the provider-neutral protected-surface inventory. use axum::http::Method; -/// Why a route deliberately does not use tenant corporate-identity auth. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityExemption { - /// Public relay metadata (NIP-05, NIP-11-adjacent information). - PublicMetadata, - /// Kubernetes/service health endpoint. - HealthProbe, - /// Public pre-membership policy and policy-acceptance bootstrap. - JoinBootstrap, - /// Deployment-global operator NIP-98 allowlist, outside tenant auth. - OperatorAuth, - /// Deployment-admin host/session authentication, outside tenant auth. - AdminAuth, - /// Per-workflow secret authentication. - WebhookSecret, - /// Loopback-only, HMAC-authenticated Git hook callback. - LocalHookCallback, - /// Disabled-by-default mesh testbed endpoint. - TestbedOnly, -} - -/// Corporate-identity policy for a registered route. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityRoutePolicy { - /// Authenticate and enforce corporate identity during this HTTP request. - Required, - /// Enforce when the upgraded WebSocket performs its protocol auth flow. - RequiredAtSessionAuth, - /// Public only when protected media reads are disabled; otherwise required. - RequiredWhenMediaReadsProtected, - /// Deliberately outside tenant corporate-identity authentication. - Exempt(CorporateIdentityExemption), -} - -impl CorporateIdentityRoutePolicy { - /// Stable, low-cardinality label used on HTTP trace spans. - pub(super) const fn trace_label(self) -> &'static str { - match self { - Self::Required => "required", - Self::RequiredAtSessionAuth => "required_at_session_auth", - Self::RequiredWhenMediaReadsProtected => "required_when_media_reads_protected", - Self::Exempt(CorporateIdentityExemption::PublicMetadata) => "exempt_public_metadata", - Self::Exempt(CorporateIdentityExemption::HealthProbe) => "exempt_health_probe", - Self::Exempt(CorporateIdentityExemption::JoinBootstrap) => "exempt_join_bootstrap", - Self::Exempt(CorporateIdentityExemption::OperatorAuth) => "exempt_operator_auth", - Self::Exempt(CorporateIdentityExemption::AdminAuth) => "exempt_admin_auth", - Self::Exempt(CorporateIdentityExemption::WebhookSecret) => "exempt_webhook_secret", - Self::Exempt(CorporateIdentityExemption::LocalHookCallback) => { - "exempt_local_hook_callback" - } - Self::Exempt(CorporateIdentityExemption::TestbedOnly) => "exempt_testbed_only", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct RoutePolicyRule { - method: &'static str, - matched_path: &'static str, - policy: CorporateIdentityRoutePolicy, -} - -const REQUIRED: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::Required; -const SESSION: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::RequiredAtSessionAuth; -const PROTECTED_MEDIA: CorporateIdentityRoutePolicy = - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected; - -const fn exempt(exemption: CorporateIdentityExemption) -> CorporateIdentityRoutePolicy { - CorporateIdentityRoutePolicy::Exempt(exemption) -} - -/// Exhaustive inventory of registered relay routes. -/// -/// Static UI fallback paths are intentionally absent: they do not have an -/// axum `MatchedPath` and cannot reach an API handler. A missing API entry is -/// visible as `unclassified` in the HTTP trace span and must be added here as -/// part of registering the route. -const ROUTE_POLICY_RULES: &[RoutePolicyRule] = &[ - // Protocol and public metadata. - RoutePolicyRule { - method: "GET", - matched_path: "/", - policy: SESSION, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/info", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/.well-known/nostr.json", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - // Health routes on the primary and health-only listeners. - RoutePolicyRule { - method: "GET", - matched_path: "/health", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_liveness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_readiness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_status", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_mesh", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - // NIP-98 HTTP bridge. - RoutePolicyRule { - method: "POST", - matched_path: "/events", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/query", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/count", - policy: REQUIRED, - }, - // Deployment-global operator control plane. - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/archive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/unarchive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities/availability", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/transfer", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - // Invite admission and its deliberately public pre-join policy surface. - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/claim", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/terms", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/privacy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/accept-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - // Moderation data is tenant-authenticated even though it is not event data. - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/reports", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/audit", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/restricted", - policy: REQUIRED, - }, - // Alternate-auth and test-only callbacks. - RoutePolicyRule { - method: "POST", - matched_path: "/hooks/{id}", - policy: exempt(CorporateIdentityExemption::WebhookSecret), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/_mesh/demo/echo", - policy: exempt(CorporateIdentityExemption::TestbedOnly), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/internal/git/policy", - policy: exempt(CorporateIdentityExemption::LocalHookCallback), - }, - // Huddle authentication is performed inside the upgraded socket. - RoutePolicyRule { - method: "GET", - matched_path: "/huddle/{channel_id}/audio", - policy: SESSION, - }, - // Blossom media: writes are always authenticated; reads are configurable. - RoutePolicyRule { - method: "PUT", - matched_path: "/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "PUT", - matched_path: "/media/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - RoutePolicyRule { - method: "HEAD", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - // Git smart HTTP is tenant-authenticated on every request. - RoutePolicyRule { - method: "GET", - matched_path: "/git/{owner}/{repo}/info/refs", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-upload-pack", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-receive-pack", - policy: REQUIRED, - }, - // Deployment-admin APIs use the dedicated admin-host auth middleware. - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}/attachments/{sha256}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, -]; +#[cfg(test)] +use crate::protected_surface::SurfaceExemption as CorporateIdentityExemption; +pub(super) use crate::protected_surface::SurfaceProtection as CorporateIdentityRoutePolicy; -/// Classify a registered method and axum matched-path template. +/// Classify a registered method and Axum matched-path template. pub(super) fn classify_matched_route( method: &Method, matched_path: &str, ) -> Option { - let exact = ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == method.as_str() && rule.matched_path == matched_path) - .map(|rule| rule.policy); - if exact.is_some() || method != Method::HEAD { - return exact; - } - - // axum automatically serves HEAD through GET routes when no explicit HEAD - // handler is registered. Mirror that routing fallback so those requests - // cannot appear unclassified. The explicit protected-media HEAD rule above - // wins before this branch. - ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == "GET" && rule.matched_path == matched_path) - .map(|rule| rule.policy) + crate::protected_surface::classify_http(method, matched_path).map(|entry| entry.protection) } -/// Whether this matched template is registered in the inventory for any -/// method. An unknown method on a known template is a 405, not a new route. +/// Whether this matched template is registered for any method. pub(super) fn is_known_matched_path(matched_path: &str) -> bool { - ROUTE_POLICY_RULES - .iter() - .any(|rule| rule.matched_path == matched_path) + crate::protected_surface::is_known_http_path(matched_path) } -/// RFC 9110 `Allow` value for a known matched template. GET routes include -/// Axum's implicit HEAD support. +/// RFC 9110 `Allow` value for a known matched template. pub(super) fn allowed_methods(matched_path: &str) -> Option { - let mut methods = Vec::new(); - for rule in ROUTE_POLICY_RULES - .iter() - .filter(|rule| rule.matched_path == matched_path) - { - if !methods.contains(&rule.method) { - methods.push(rule.method); - } - if rule.method == "GET" && !methods.contains(&"HEAD") { - methods.push("HEAD"); - } - } - (!methods.is_empty()).then(|| methods.join(", ")) + crate::protected_surface::allowed_http_methods(matched_path) } #[cfg(test)] mod tests { - use std::collections::HashSet; - use super::*; + use buzz_auth::AuthorizationCapability; fn policy(method: Method, path: &str) -> CorporateIdentityRoutePolicy { classify_matched_route(&method, path) @@ -371,129 +35,111 @@ mod tests { } #[test] - fn every_policy_rule_has_a_unique_method_and_path() { - let mut seen = HashSet::new(); - for rule in ROUTE_POLICY_RULES { - assert!( - seen.insert((rule.method, rule.matched_path)), - "duplicate route policy for {} {}", - rule.method, - rule.matched_path - ); - } - } - - #[test] - fn every_tenant_authenticated_http_route_requires_corporate_identity() { - let routes = [ - (Method::POST, "/events"), - (Method::POST, "/query"), - (Method::POST, "/count"), - (Method::POST, "/api/invites"), - (Method::POST, "/api/invites/claim"), - (Method::GET, "/moderation/reports"), - (Method::GET, "/moderation/audit"), - (Method::GET, "/moderation/restricted"), - (Method::PUT, "/upload"), - (Method::PUT, "/media/upload"), - (Method::GET, "/git/{owner}/{repo}/info/refs"), - (Method::POST, "/git/{owner}/{repo}/git-upload-pack"), - (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + fn tenant_routes_map_to_exact_portable_capabilities() { + let read_routes = [ + ( + Method::POST, + "/query", + AuthorizationCapability::CommunityRead, + ), + ( + Method::GET, + "/moderation/reports", + AuthorizationCapability::Moderate, + ), ]; - for (method, path) in routes { - assert_eq!(policy(method, path), CorporateIdentityRoutePolicy::Required); + for (method, path, capability) in read_routes { + assert_eq!( + policy(method, path), + CorporateIdentityRoutePolicy::Capability(capability) + ); } - } - - #[test] - fn websocket_and_media_policies_capture_deferred_and_conditional_auth() { - assert_eq!( - policy(Method::GET, "/"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - assert_eq!( - policy(Method::GET, "/huddle/{channel_id}/audio"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - for method in [Method::GET, Method::HEAD] { + let unavailable_mutation_routes = [( + Method::POST, + "/hooks/{id}", + AuthorizationCapability::CommunityWrite, + )]; + for (method, path, capability) in unavailable_mutation_routes { assert_eq!( - policy(method, "/media/{sha256_ext}"), - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected + policy(method, path), + CorporateIdentityRoutePolicy::AtomicMutationUnavailable(capability) ); } - } - - #[test] - fn privileged_non_tenant_surfaces_have_narrow_named_exemptions() { - let routes = [ + for (method, path, capability) in [ ( Method::POST, - "/operator/communities/archive", - CorporateIdentityExemption::OperatorAuth, - ), - ( - Method::GET, - "/api/admin/v1/reports", - CorporateIdentityExemption::AdminAuth, + "/api/invites", + AuthorizationCapability::InviteMint, ), ( Method::POST, - "/hooks/{id}", - CorporateIdentityExemption::WebhookSecret, + "/api/invites/claim", + AuthorizationCapability::InviteClaim, ), + (Method::PUT, "/upload", AuthorizationCapability::MediaWrite), ( Method::POST, - "/internal/git/policy", - CorporateIdentityExemption::LocalHookCallback, + "/git/{owner}/{repo}/git-receive-pack", + AuthorizationCapability::GitWrite, ), - ]; - for (method, path, exemption) in routes { + ] { assert_eq!( policy(method, path), - CorporateIdentityRoutePolicy::Exempt(exemption) + CorporateIdentityRoutePolicy::AtomicMutation(capability) ); } + assert_eq!( + policy(Method::POST, "/events"), + CorporateIdentityRoutePolicy::DynamicAtomicMutation + ); + assert_eq!( + policy(Method::GET, "/git/{owner}/{repo}/info/refs"), + CorporateIdentityRoutePolicy::DynamicCapability + ); } #[test] - fn public_routes_are_explicit_and_unknown_routes_are_unclassified() { + fn websocket_and_media_policies_are_deferred_or_conditional() { + assert_eq!( + policy(Method::GET, "/"), + CorporateIdentityRoutePolicy::ConditionalWebSocketUpgrade + ); assert_eq!( - policy(Method::GET, "/.well-known/nostr.json"), + policy(Method::HEAD, "/"), CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); assert_eq!( - policy(Method::GET, "/_readiness"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) + policy(Method::GET, "/huddle/{channel_id}/audio"), + CorporateIdentityRoutePolicy::LeasedSession { + capability: AuthorizationCapability::AudioJoin + } ); + for method in [Method::GET, Method::HEAD] { + assert_eq!( + policy(method, "/media/{sha256_ext}"), + CorporateIdentityRoutePolicy::ConditionalMediaRead( + AuthorizationCapability::MediaRead + ) + ); + } + } + + #[test] + fn exemptions_are_narrow_and_unknown_routes_are_unclassified() { assert_eq!( - policy(Method::GET, "/api/join-policy"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::JoinBootstrap) + policy(Method::GET, "/_readiness"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) ); assert_eq!( - policy(Method::POST, "/_mesh/demo/echo"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::TestbedOnly) + policy(Method::POST, "/internal/git/policy"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::LocalHookCallback) ); assert_eq!( policy(Method::HEAD, "/info"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata), - "axum's automatic GET-to-HEAD fallback inherits the GET policy" + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); - assert_eq!(classify_matched_route(&Method::GET, "/events"), None); assert_eq!(classify_matched_route(&Method::GET, "/unknown"), None); - assert_eq!( - classify_matched_route(&Method::GET, "/media/literal-sha"), - None, - "the classifier accepts trusted matched templates, not literal paths" - ); - } - - #[test] - fn known_path_detection_distinguishes_method_fallbacks_from_new_routes() { assert!(is_known_matched_path("/events")); - assert!(is_known_matched_path("/health")); - assert!(!is_known_matched_path("/new-unclassified-route")); assert_eq!(allowed_methods("/events").as_deref(), Some("POST")); - assert_eq!(allowed_methods("/info").as_deref(), Some("GET, HEAD")); - assert_eq!(allowed_methods("/new-unclassified-route"), None); } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 6271d6cdec..55658369be 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -16,10 +16,10 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use buzz_audit::AuditService; -use buzz_auth::{AuthService, Nip98ReplayGuard}; +use buzz_auth::{AuthService, Nip98ReplayGuard, VerifiedFederatedAssertion, VerifiedNostrProof}; use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; -use buzz_db::Db; +use buzz_db::{authorization_invalidation::AuthorizationSessionTarget, Db}; use buzz_media::MediaStorage; use buzz_pubsub::cache_invalidation::CacheInvalidation; use buzz_pubsub::conn_control::ConnControl; @@ -32,6 +32,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; use crate::connection::ConnectionSubscriptions; +use crate::connection::OutboundData; use crate::corporate_identity::CorporateIdentityService; use crate::subscription::SubscriptionRegistry; @@ -41,7 +42,9 @@ type ScopedRateLimiter = DashMap; /// Per-connection entry in the connection manager. struct ConnEntry { - tx: mpsc::Sender, + /// Exact server-issued identity of this connection issuance. + authorization_session_target: AuthorizationSessionTarget, + tx: mpsc::Sender, /// Control-frame sender, drained ahead of data and before cancel wins in /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). @@ -55,9 +58,57 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + authenticated_owner_pubkey: Arc>>>, + /// Sealed NIP-42 proof retained for every protected operation on this + /// connection. Legacy test registrations may intentionally leave it empty. + verified_nostr_proof: Arc>>>, + /// Current direct federated evidence sealed during authentication. + verified_federated_assertion: Arc>>>, + /// The enforcing authority and its hard-expiry task share one lock so + /// concurrent operations can only tighten, never extend, the session. + protected_session: Arc>, grace_limit: u8, } +struct ProtectedSessionExpiryTask { + deadline: u64, + cancel: CancellationToken, +} + +#[derive(Default)] +struct ProtectedSessionState { + /// Retaining this value keeps its invalidation observer registered until + /// tighter authority replaces it or the connection is removed. + authority: Option>, + expiry: Option, +} + +fn should_replace_protected_session_deadline(current: Option, candidate: u64) -> bool { + current.is_none_or(|current| candidate < current) +} + +fn protected_session_wake_at_from_samples( + deadline: u64, + monotonic_anchor: tokio::time::Instant, + wall_now: std::time::Duration, + coarse_delay: std::time::Duration, +) -> Option { + let wall_remaining = std::time::Duration::from_secs(deadline).checked_sub(wall_now)?; + let conservative_coarse = coarse_delay.saturating_sub(std::time::Duration::from_secs(1)); + monotonic_anchor.checked_add(wall_remaining.min(conservative_coarse)) +} + +fn protected_session_wake_at( + deadline: u64, + monotonic_anchor: tokio::time::Instant, + coarse_delay: std::time::Duration, +) -> Option { + let wall_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + protected_session_wake_at_from_samples(deadline, monotonic_anchor, wall_now, coarse_delay) +} + /// Community-scoped lifecycle registry shared by every long-lived socket type. /// /// A handler registers before durable active-state revalidation. Archival after @@ -206,7 +257,7 @@ impl ConnectionManager { pub fn register( &self, conn_id: Uuid, - tx: mpsc::Sender, + tx: mpsc::Sender, ctrl_tx: mpsc::Sender, cancel: CancellationToken, community_id: CommunityId, @@ -214,11 +265,18 @@ impl ConnectionManager { subscriptions: ConnectionSubscriptions, grace_limit: u8, ) { + let Ok(authorization_session_target) = + AuthorizationSessionTarget::new(conn_id, Uuid::new_v4()) + else { + cancel.cancel(); + return; + }; let drain_ctrl_tx = ctrl_tx.clone(); let drain_cancel = cancel.clone(); self.connections.insert( conn_id, ConnEntry { + authorization_session_target, tx, ctrl_tx, cancel, @@ -226,6 +284,12 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + authenticated_owner_pubkey: Arc::new(std::sync::RwLock::new(None)), + verified_nostr_proof: Arc::new(std::sync::RwLock::new(None)), + verified_federated_assertion: Arc::new(std::sync::RwLock::new(None)), + protected_session: Arc::new( + std::sync::Mutex::new(ProtectedSessionState::default()), + ), grace_limit, }, ); @@ -241,7 +305,64 @@ impl ConnectionManager { /// Removes a connection from the registry. pub fn deregister(&self, conn_id: Uuid) { - self.connections.remove(&conn_id); + if let Some((_, entry)) = self.connections.remove(&conn_id) { + Self::clear_protected_session(&entry); + } + } + + fn clear_protected_session(entry: &ConnEntry) { + if let Ok(mut session) = entry.protected_session.lock() { + if let Some(task) = session.expiry.take() { + task.cancel.cancel(); + } + session.authority = None; + } else { + entry.cancel.cancel(); + } + } + + /// Atomically retain only authority with an earlier hard deadline. + /// + /// The comparison, task replacement, and authority replacement must stay + /// in this critical section: WebSocket handlers for one connection run + /// concurrently and a split read/install would permit lease extension. + fn retain_earlier_protected_session( + entry: &ConnEntry, + deadline: u64, + wake_at: tokio::time::Instant, + authority: Option>, + after_read_hook: Option<&dyn Fn()>, + ) -> bool { + if let Ok(mut session) = entry.protected_session.lock() { + let current = session.expiry.as_ref().map(|task| task.deadline); + if let Some(hook) = after_read_hook { + hook(); + } + if !should_replace_protected_session_deadline(current, deadline) { + return false; + } + if let Some(previous) = session.expiry.take() { + previous.cancel.cancel(); + } + let expiry_task = CancellationToken::new(); + let expiry_cancel = expiry_task.clone(); + let connection_cancel = entry.cancel.clone(); + tokio::spawn(async move { + tokio::select! { + _ = expiry_cancel.cancelled() => {} + _ = tokio::time::sleep_until(wake_at) => connection_cancel.cancel(), + } + }); + session.expiry = Some(ProtectedSessionExpiryTask { + deadline, + cancel: expiry_task, + }); + session.authority = authority; + true + } else { + entry.cancel.cancel(); + false + } } /// Record the authenticated pubkey for a connection after NIP-42 succeeds. @@ -250,6 +371,81 @@ impl ConnectionManager { if let Ok(mut slot) = entry.authenticated_pubkey.write() { *slot = Some(pubkey_bytes); } + if let Ok(mut slot) = entry.authenticated_owner_pubkey.write() { + *slot = None; + } + if let Ok(mut slot) = entry.verified_nostr_proof.write() { + *slot = None; + } + if let Ok(mut slot) = entry.verified_federated_assertion.write() { + *slot = None; + } + Self::clear_protected_session(&entry); + } + } + + /// Record sealed connection evidence and derive actor/owner indexes from it. + pub fn set_authenticated_authority( + &self, + conn_id: Uuid, + proof: Arc, + assertion: Option>, + ) { + if let Some(entry) = self.connections.get(&conn_id) { + if let Ok(mut slot) = entry.authenticated_pubkey.write() { + *slot = Some(proof.actor_pubkey().to_bytes().to_vec()); + } + if let Ok(mut slot) = entry.authenticated_owner_pubkey.write() { + *slot = proof + .verified_delegation() + .map(|delegation| delegation.owner_pubkey().to_bytes().to_vec()); + } + if let Ok(mut slot) = entry.verified_nostr_proof.write() { + *slot = Some(proof); + } + if let Ok(mut slot) = entry.verified_federated_assertion.write() { + *slot = assertion; + } + Self::clear_protected_session(&entry); + } + } + + /// Retain the latest enforcing authority for an established connection. + /// Legacy and observational results clear any older authority without + /// creating a new invalidation registration. + pub fn retain_protected_session_authority( + &self, + conn_id: Uuid, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + ) { + if let Some(entry) = self.connections.get(&conn_id) { + if authority.is_enforcing() { + let candidate = authority.expires_at().unwrap_or_default(); + // Anchor monotonic time before consulting the injected + // whole-second authorization clock. Time spent sampling or + // installing the task must consume authority, never extend it. + let monotonic_anchor = tokio::time::Instant::now(); + match authority.expiry_delay() { + Ok(Some(delay)) => { + if let Some(wake_at) = + protected_session_wake_at(candidate, monotonic_anchor, delay) + { + Self::retain_earlier_protected_session( + &entry, + candidate, + wake_at, + Some(Arc::new(authority.clone())), + None, + ); + } else { + entry.cancel.cancel(); + } + } + Ok(None) | Err(_) => entry.cancel.cancel(), + } + } else { + Self::clear_protected_session(&entry); + } } } @@ -290,6 +486,47 @@ impl ConnectionManager { .and_then(|entry| entry.authenticated_pubkey.read().ok()?.clone()) } + /// Return the sealed verifier evidence recorded for a connection. + pub fn authority_for_conn(&self, conn_id: Uuid) -> Option> { + self.connections + .get(&conn_id) + .and_then(|entry| entry.verified_nostr_proof.read().ok()?.clone()) + } + + /// Return the exact server-issued target for one live connection issuance. + pub fn authorization_session_target( + &self, + conn_id: Uuid, + ) -> Option { + self.connections + .get(&conn_id) + .map(|entry| entry.authorization_session_target) + } + + /// Return current direct federated evidence recorded for a connection. + pub fn federated_assertion_for_conn( + &self, + conn_id: Uuid, + ) -> Option> { + self.connections + .get(&conn_id) + .and_then(|entry| entry.verified_federated_assertion.read().ok()?.clone()) + } + + /// Return the server-owned cancellation token for a live connection. + pub fn cancellation_for_conn(&self, conn_id: Uuid) -> Option { + self.connections + .get(&conn_id) + .map(|entry| entry.cancel.clone()) + } + + /// Cancel one connection after protected session authority expires. + pub fn cancel_connection(&self, conn_id: Uuid) { + if let Some(entry) = self.connections.get(&conn_id) { + entry.cancel.cancel(); + } + } + /// Disconnect every live connection authenticated as `pubkey` **in /// `community`**, delivering a final `OK false` frame carrying `reason` /// before closing. @@ -435,7 +672,7 @@ impl ConnectionManager { /// On sustained backpressure (>grace_limit consecutive full buffers), /// cancels the connection. Transient stalls get a warning only. pub fn send_to(&self, conn_id: Uuid, msg: String) -> bool { - self.try_send_ws_message(conn_id, WsMessage::Text(msg.into())) + self.try_send_outbound(conn_id, OutboundData::plain(WsMessage::Text(msg.into()))) } /// Sends an already-serialized UTF-8 text payload to the given connection. @@ -445,10 +682,73 @@ impl ConnectionManager { pub fn send_to_text_bytes(&self, conn_id: Uuid, msg: Arc) -> bool { let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) .expect("relay fan-out frames are serialized UTF-8 JSON"); - self.try_send_ws_message(conn_id, WsMessage::Text(text)) + self.try_send_outbound(conn_id, OutboundData::plain(WsMessage::Text(text))) + } + + /// Queue protected text and retain its exact authority until socket drain. + pub fn send_to_text_bytes_protected( + &self, + conn_id: Uuid, + msg: Arc, + authority: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::protected(WsMessage::Text(text), authority), + ) + } + + /// Queue output guarded by both the emitting operation and the recipient's + /// current read authority until socket drain. + pub fn send_to_text_bytes_protected_pair( + &self, + conn_id: Uuid, + msg: Arc, + sender: Arc, + recipient: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::protected_pair(WsMessage::Text(text), sender, recipient), + ) + } + + /// Queue output behind an arbitrary asynchronous sender fence. + pub(crate) fn send_to_text_bytes_guarded( + &self, + conn_id: Uuid, + msg: Arc, + authority: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::guarded(WsMessage::Text(text), authority), + ) + } + + /// Queue output behind a remote sender fence and local recipient fence. + pub(crate) fn send_to_text_bytes_guarded_pair( + &self, + conn_id: Uuid, + msg: Arc, + sender: Arc, + recipient: Arc, + ) -> bool { + let text = WsUtf8Bytes::try_from(Bytes::clone(msg.as_ref())) + .expect("relay fan-out frames are serialized UTF-8 JSON"); + self.try_send_outbound( + conn_id, + OutboundData::guarded_pair(WsMessage::Text(text), sender, recipient), + ) } - fn try_send_ws_message(&self, conn_id: Uuid, msg: WsMessage) -> bool { + fn try_send_outbound(&self, conn_id: Uuid, msg: OutboundData) -> bool { if let Some(entry) = self.connections.get(&conn_id) { let conn = entry.value(); match conn.tx.try_send(msg) { @@ -628,6 +928,36 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, + /// Optional exact-domain protected-transport runtime. + /// + /// Unset preserves legacy behavior. Once installed it is immutable for the + /// process lifetime so request data cannot switch provider policy. + pub protected_transport: Arc< + std::sync::OnceLock< + Arc, + >, + >, + /// Deployment-verified ingress provenance for direct identity assertions. + /// + /// Unset by the stock binary. Header presence alone is never trusted. + pub identity_assertion_provenance: Arc< + std::sync::OnceLock< + Arc, + >, + >, + /// Complete-stack-gated NIP-FI discovery. The stock binary leaves this + /// unset; only an immutable reviewed conformance input can construct it. + pub nip_fi_discovery: Arc>, + /// Optional RFC-gated, dedicated client-status presentation runtime. + pub client_status_runtime: Arc< + std::sync::OnceLock< + Arc, + >, + >, + /// Independent version witness installed with protected authorization. + pub restore_protection: Arc< + std::sync::OnceLock>, + >, } impl AppState { @@ -804,6 +1134,11 @@ 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()), + nip_fi_discovery: Arc::new(std::sync::OnceLock::new()), + client_status_runtime: Arc::new(std::sync::OnceLock::new()), + restore_protection: Arc::new(std::sync::OnceLock::new()), }; ( state, @@ -820,6 +1155,99 @@ impl AppState { self.mesh.get() } + /// Install immutable protected-transport policy exactly once. + pub fn install_protected_transport( + &self, + runtime: Arc, + ) -> Result<(), Arc> { + self.protected_transport.set(runtime) + } + + /// Current protected-transport runtime, if configured. + pub fn protected_transport( + &self, + ) -> Option<&Arc> { + self.protected_transport.get() + } + + /// Install the immutable deployment-owned ingress provenance adapter. + pub fn install_identity_assertion_provenance( + &self, + verifier: Arc, + ) -> Result<(), Arc> { + self.identity_assertion_provenance.set(verifier) + } + + /// Return the installed ingress provenance adapter, if any. + pub fn identity_assertion_provenance( + &self, + ) -> Option<&Arc> { + self.identity_assertion_provenance.get() + } + + /// Install exact-revision NIP-FI discovery readiness once. + pub fn install_nip_fi_discovery( + &self, + ready: crate::nip11::ConformanceReadyNipFiDiscovery, + ) -> Result<(), crate::nip11::ConformanceReadyNipFiDiscovery> { + self.nip_fi_discovery.set(ready) + } + + /// Return complete-stack-gated NIP-FI discovery, if installed. + pub fn nip_fi_discovery(&self) -> Option<&crate::nip11::ConformanceReadyNipFiDiscovery> { + self.nip_fi_discovery.get() + } + + /// Install an externally approved dedicated client-status runtime once. + pub fn install_client_status_runtime( + &self, + runtime: Arc, + ) -> Result<(), Arc> { + self.client_status_runtime.set(runtime) + } + + /// Return the approved client-status runtime, if installed. + pub fn client_status_runtime( + &self, + ) -> Option<&Arc> { + self.client_status_runtime.get() + } + + /// Install the restore-independent version witness exactly once. + pub fn install_restore_protection( + &self, + runtime: Arc, + ) -> Result<(), Arc> { + self.restore_protection.set(runtime) + } + + /// Current restore-independent version witness, if protected mode exists. + pub fn restore_protection( + &self, + ) -> Option<&Arc> { + self.restore_protection.get() + } + + /// Database UUIDs for exact domains where autonomous effects must not run. + pub fn enforcing_protected_domain_ids(&self) -> Vec { + self.protected_transport() + .map(|runtime| { + runtime + .enforcing_domains() + .into_iter() + .map(|domain| *domain.as_uuid()) + .collect() + }) + .unwrap_or_default() + } + + /// Whether one exact server-resolved domain is in authoritative Enforce. + 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 @@ -1237,7 +1665,7 @@ mod tests { ) -> ( ConnectionManager, Uuid, - mpsc::Receiver, + mpsc::Receiver, mpsc::Receiver, CancellationToken, Arc, @@ -1261,6 +1689,228 @@ mod tests { (mgr, conn_id, rx, ctrl_rx, cancel, bp) } + #[tokio::test(start_paused = true)] + async fn protected_session_deadline_is_conservative_and_anchor_bound() { + let anchor = tokio::time::Instant::now(); + let wake_at = protected_session_wake_at_from_samples( + 102, + anchor, + std::time::Duration::from_millis(100_900), + std::time::Duration::from_secs(2), + ) + .expect("future deadline"); + assert_eq!( + wake_at.duration_since(anchor), + std::time::Duration::from_secs(1), + "whole-second authority is shortened conservatively instead of rounded late" + ); + + // Simulate work between authority-clock sampling and task install. The + // absolute monotonic wake stays tied to the earlier anchor. + tokio::time::advance(std::time::Duration::from_millis(750)).await; + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session(&entry, 102, wake_at, None, None); + drop(entry); + + tokio::time::advance(std::time::Duration::from_millis(249)).await; + tokio::task::yield_now().await; + assert!(!cancel.is_cancelled()); + tokio::time::advance(std::time::Duration::from_millis(1)).await; + tokio::task::yield_now().await; + assert!(cancel.is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn protected_session_expiry_never_extends_and_disconnect_cleans_task() { + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(10), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(9)).await; + assert!(!cancel.is_cancelled()); + + assert!(!should_replace_protected_session_deadline(Some(10), 20)); + assert!(!should_replace_protected_session_deadline(Some(10), 10)); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!( + cancel.is_cancelled(), + "ordinary traffic cannot extend the first hard session deadline" + ); + + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 20, + tokio::time::Instant::now() + std::time::Duration::from_secs(20), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(5)).await; + assert!(should_replace_protected_session_deadline(Some(20), 10)); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + None, + None, + ); + } + tokio::task::yield_now().await; + tokio::time::advance(std::time::Duration::from_secs(5)).await; + tokio::task::yield_now().await; + assert!( + cancel.is_cancelled(), + "a shorter authority tightens the hard deadline" + ); + + let (mgr, conn_id, _rx, _ctrl_rx, cancel, _bp) = setup_conn(8); + { + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + ConnectionManager::retain_earlier_protected_session( + &entry, + 5, + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + None, + None, + ); + } + tokio::task::yield_now().await; + mgr.deregister(conn_id); + tokio::time::advance(std::time::Duration::from_secs(5)).await; + assert!( + !cancel.is_cancelled(), + "disconnect removes the obsolete expiry task" + ); + } + + #[tokio::test] + async fn concurrent_protected_operations_cannot_replace_an_earlier_deadline() { + let (mgr, conn_id, _rx, _ctrl_rx, _cancel, _bp) = setup_conn(8); + let mgr = Arc::new(mgr); + let entry = mgr + .connections + .get(&conn_id) + .expect("registered connection"); + let protected_session = Arc::clone(&entry.protected_session); + drop(entry); + + let runtime = tokio::runtime::Handle::current(); + let short_authority = + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy); + let long_authority = + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy); + let (short_locked, wait_for_short_lock) = std::sync::mpsc::channel(); + let (release_short, wait_for_release) = std::sync::mpsc::channel(); + let short_manager = Arc::clone(&mgr); + let retained_short_authority = Arc::clone(&short_authority); + let short_runtime = runtime.clone(); + let short = std::thread::spawn(move || { + let _runtime = short_runtime.enter(); + let entry = short_manager + .connections + .get(&conn_id) + .expect("registered connection"); + let hook = || { + short_locked.send(()).expect("test still waiting"); + wait_for_release.recv().expect("test releases first lock"); + }; + ConnectionManager::retain_earlier_protected_session( + &entry, + 10, + tokio::time::Instant::now() + std::time::Duration::from_secs(10), + Some(retained_short_authority), + Some(&hook), + ); + }); + wait_for_short_lock + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("short contender holds the comparison/install lock"); + + let (long_attempting, wait_for_long_attempt) = std::sync::mpsc::channel(); + let (long_read, wait_for_long_read) = std::sync::mpsc::channel(); + let long_manager = Arc::clone(&mgr); + let long_runtime = runtime.clone(); + let long = std::thread::spawn(move || { + let _runtime = long_runtime.enter(); + long_attempting.send(()).expect("test still waiting"); + let entry = long_manager + .connections + .get(&conn_id) + .expect("registered connection"); + let hook = || { + long_read.send(()).expect("test still waiting"); + }; + ConnectionManager::retain_earlier_protected_session( + &entry, + 20, + tokio::time::Instant::now() + std::time::Duration::from_secs(20), + Some(long_authority), + Some(&hook), + ); + }); + wait_for_long_attempt + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("long contender reached the atomic helper"); + assert!( + matches!( + wait_for_long_read.recv_timeout(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "a second contender cannot read the deadline before the first install completes" + ); + release_short.send(()).expect("short contender is waiting"); + short.join().expect("short retention thread"); + wait_for_long_read + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("long contender reads only after the short install"); + long.join().expect("long retention thread"); + + let session = protected_session.lock().expect("protected session lock"); + assert_eq!( + session.expiry.as_ref().map(|task| task.deadline), + Some(10), + "the minimum concurrent deadline is retained regardless of completion order" + ); + assert!( + session + .authority + .as_ref() + .is_some_and(|authority| Arc::ptr_eq(authority, &short_authority)), + "the retained authority belongs to the minimum-deadline contender" + ); + } + async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; @@ -1363,7 +2013,7 @@ mod tests { "test.local".to_string(), ), remote_addr: "127.0.0.1:1234".parse().unwrap(), - corporate_identity_jwt: None, + corporate_identity_assertion: None, auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), diff --git a/crates/buzz-relay/src/storage_sweep.rs b/crates/buzz-relay/src/storage_sweep.rs index eccadcd835..241748d970 100644 --- a/crates/buzz-relay/src/storage_sweep.rs +++ b/crates/buzz-relay/src/storage_sweep.rs @@ -25,6 +25,7 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use uuid::Uuid; +use buzz_core::CommunityId; use buzz_media::{BucketSnapshot, SweepError}; /// Sweep knobs, read once at boot. See `PLANS/S3_STORAGE_METRICS_PLAN.md` F7. @@ -107,9 +108,9 @@ struct SweepAttempt { /// renamed, or scope-excluded) are zeroed rather than left at their last /// nonzero value until the recorder's idle-eviction kicks in. /// -/// Carries the resolved host label (not the UUID) so a rename can still zero -/// the old series, and distinguishes bytes vs. objects because they are -/// separate Prometheus series. +/// Carries a stable runtime pseudonym rather than a host or UUID, and +/// distinguishes bytes vs. objects because they are separate Prometheus +/// series. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum StorageEmittedKey { Bytes(String), @@ -119,12 +120,12 @@ pub(crate) enum StorageEmittedKey { impl StorageEmittedKey { fn set(&self, value: f64) { match self { - Self::Bytes(host) => { - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + Self::Bytes(label) => { + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(value); } - Self::Objects(host) => { - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + Self::Objects(label) => { + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(value); } } @@ -263,15 +264,15 @@ pub async fn maybe_spawn_sweep( /// never from the spawned sweep task itself, so a sweep that completes after /// this pod loses leadership parks its snapshot without ever publishing it. /// -/// `host_map` resolves a community UUID to its label string for per- -/// community series; `allows` gates those series the same way +/// `host_map` proves that a community UUID still resolves to a live tenant; +/// the emitted label is an opaque runtime pseudonym. `allows` gates those series the same way /// `EmissionScope` gates the DB-derived ones. A bound community UUID absent /// from `host_map` is "unmapped" (sidecar references a community with no DB /// row) and rolls into `buzz_storage_unmapped_community_bytes` instead of a /// per-community series. /// /// Per-community series whose community disappears from the current snapshot -/// (unmapped, host rename, or scope exclusion) are explicitly zeroed — the +/// (unmapped or scope exclusion) are explicitly zeroed — the /// same pattern as `emit_in_memory_usage_metrics`. Without this, a series /// would linger at its last nonzero value until the recorder's idle eviction /// fires (≥3 ticks), producing a transient double-count against the @@ -326,19 +327,20 @@ pub async fn emit_storage_metrics( let mut current = HashSet::new(); let mut unmapped_bytes = 0u64; for (community_id, storage) in &snapshot.per_community { - let Some(host) = host_map.get(community_id) else { + if !host_map.contains_key(community_id) { unmapped_bytes += storage.bytes; continue; - }; + } if !allows(community_id) { continue; } - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + let label = crate::metrics::community_label(CommunityId::from_uuid(*community_id)); + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(storage.bytes as f64); - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(storage.objects as f64); - current.insert(StorageEmittedKey::Bytes(host.clone())); - current.insert(StorageEmittedKey::Objects(host.clone())); + current.insert(StorageEmittedKey::Bytes(label.clone())); + current.insert(StorageEmittedKey::Objects(label)); } metrics::gauge!("buzz_storage_unmapped_community_bytes").set(unmapped_bytes as f64); @@ -982,6 +984,9 @@ mod tests { }); let recorder = DebuggingRecorder::new(); + let label_a = crate::metrics::community_label(CommunityId::from_uuid(community_a)); + let label_b = crate::metrics::community_label(CommunityId::from_uuid(community_b)); + let label_c = crate::metrics::community_label(CommunityId::from_uuid(community_c)); // --- Emission 1: all three communities visible --- let mut host_map_1 = HashMap::new(); @@ -994,18 +999,12 @@ mod tests { { let labeled = labeled_community_gauges(&recorder); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&10.0), "emission 1: host.a bytes should be 10" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), "emission 1: host.old bytes should be 20" ); @@ -1033,56 +1032,36 @@ mod tests { let labeled = labeled_community_gauges(&recorder); - // (a) community_a disappeared — old host.a series must be zeroed + // (a) community_a disappeared — its pseudonymous series is zeroed. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&0.0), "(a) disappeared community: host.a bytes must be zeroed" ); assert_eq!( labeled.get(&( "buzz_community_storage_objects".to_string(), - "host.a".to_string() + label_a.clone() )), Some(&0.0), "(a) disappeared community: host.a objects must be zeroed" ); - // (b) community_b renamed host.old → host.new — old series must be zeroed - assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), - Some(&0.0), - "(b) host rename: host.old bytes must be zeroed" - ); + // (b) a host rename retains the same non-host label and value. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.new".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), - "(b) host rename: host.new bytes must be 20" + "(b) host rename must not expose or churn a tenant-host label" ); // (c) community_c scope-excluded — host.c series must be zeroed assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_c.clone())), Some(&0.0), "(c) scope removal: host.c bytes must be zeroed" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_objects".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_objects".to_string(), label_c)), Some(&0.0), "(c) scope removal: host.c objects must be zeroed" ); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..9bec1be734 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -19,6 +19,73 @@ use uuid::Uuid; use crate::handlers::event::dispatch_persistent_event; use crate::state::AppState; +/// Relay-owned provider-neutral workflow mutation gate. +/// +/// The weak application-state reference avoids a cycle through +/// `AppState -> WorkflowEngine -> MutationGate -> AppState`. +pub struct RelayWorkflowMutationGate { + state: Weak, +} + +impl RelayWorkflowMutationGate { + /// Create a gate backed by the relay's immutable protected-domain policy. + pub fn new(state: &Arc) -> Self { + Self { + state: Arc::downgrade(state), + } + } +} + +impl buzz_workflow::MutationGate for RelayWorkflowMutationGate { + fn require_mutation( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + })?; + 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(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + }) + } + + fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + })?; + 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::OutboundWebhook, + ) + .map(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + }) + } +} + /// Resolves `@Name` mentions in workflow message text to the pubkeys of the /// channel members they name, so the emitted kind:9 carries the `p` tags that /// ACP agent-wake (`event_mentions_agent`) is gated on. @@ -188,6 +255,17 @@ impl ActionSink for RelayActionSink { .upgrade() .ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?; + // A delayed action may outlive the authority that started its run. + // With no transaction-owning workflow executor, Enforce must stop + // before tenant lookup, event construction, persistence, or fanout. + crate::authorization_runtime::transport::require_unwired_atomic_mutation_if_configured( + &state, + community_id, + ) + .map_err(|_| { + ActionSinkError::Database("protected workflow mutation unavailable".into()) + })?; + // The run carries its owning community (`community_id`); the // relay-signed kind:9 message belongs to *that* community, never the // deployment default. Re-deriving the tenant from `config.relay_url` @@ -567,10 +645,41 @@ mod integration_tests { //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` use super::*; + use async_trait::async_trait; + use buzz_auth::{AuthorizationClock, AuthorizationClockError, AuthorizationTime}; use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; use buzz_db::CreateCommunityWithOwnerResult; use std::sync::Arc; + struct UnavailableResolver; + + #[async_trait] + impl crate::authorization_runtime::transport::ProtectedAuthorizationResolver + for UnavailableResolver + { + async fn resolve( + &self, + _request: &crate::authorization_runtime::transport::ProtectedOperationRequest, + ) -> Result< + crate::authorization_runtime::transport::ProtectedResolution, + crate::authorization_runtime::transport::ProtectedResolutionError, + > { + Err( + crate::authorization_runtime::transport::ProtectedResolutionError::new( + "synthetic_unavailable", + ), + ) + } + } + + struct FixedClock; + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(100)) + } + } + /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); @@ -609,6 +718,75 @@ mod integration_tests { Arc::new(state) } + #[tokio::test] + async fn workflow_action_enforce_without_executor_persists_no_event() { + let state = test_state().await; + let community = CommunityId::from_uuid(Uuid::from_u128(0xF10)); + let runtime = crate::authorization_runtime::transport::ProtectedTransportRuntime::new( + [ + crate::authorization_runtime::transport::DomainTransportPolicy::from_server_configuration( + community, + crate::authorization_runtime::finalization::AuthorizationMode::Enforce, + ), + ], + Arc::new(UnavailableResolver), + Arc::new(FixedClock), + ) + .expect("synthetic protected runtime"); + state + .install_protected_transport(Arc::new(runtime)) + .expect("install protected runtime once"); + state + .workflow_engine + .set_mutation_gate(Arc::new(RelayWorkflowMutationGate::new(&state))); + + let trigger = buzz_workflow::executor::TriggerContext { + message_id: "synthetic-event".into(), + ..Default::default() + }; + for action in [ + buzz_workflow::ActionDef::AddReaction { + emoji: "check".into(), + }, + buzz_workflow::ActionDef::CallWebhook { + url: "https://example.invalid/hook".into(), + method: None, + headers: None, + body: None, + }, + ] { + let error = buzz_workflow::executor::dispatch_action( + "blocked", + &action, + &state.workflow_engine, + community, + Uuid::from_u128(2), + &trigger, + ) + .await + .expect_err("direct workflow effects must stop at the central gate"); + assert!(matches!( + error, + buzz_workflow::WorkflowError::Unauthorized(_) + )); + } + + let error = RelayActionSink::new(&state) + .send_message( + community, + &Uuid::from_u128(1).to_string(), + "must not persist", + &nostr::Keys::generate().public_key().to_hex(), + ) + .await + .expect_err("Enforce without an executor must fail before persistence"); + + assert_eq!( + error.to_string(), + "database error: protected workflow mutation unavailable" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn workflow_send_message_p_tags_mentioned_member() { diff --git a/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json new file mode 100644 index 0000000000..affe64ec70 --- /dev/null +++ b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "fixture_classification": "synthetic-only", + "full_stack_conformance_claim": false, + "presentation_gate": "disabled", + "trusted_proxy_cases": [ + { + "row": "TR-1.direct-bypass", + "origin_isolation_enforced": false, + "inbound_assertion_header_stripped": true, + "expected": "deny-before-verification" + }, + { + "row": "TR-1.inbound-header-copy", + "origin_isolation_enforced": true, + "inbound_assertion_header_stripped": false, + "expected": "deny-before-verification" + }, + { + "row": "TR-1.complete-deployment-evidence", + "origin_isolation_enforced": true, + "inbound_assertion_header_stripped": true, + "expected": "eligible-for-provider-verification" + } + ], + "nip_fi_rows": [ + { + "row": "AS-3.future-iat", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "BD-1.cross-domain", + "owner": "authorization-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "SE-4.invalidation", + "owner": "invalidation-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "DG-3.no-finite-bound", + "owner": "delegation-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "OP-2.discovery", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "OP-3.absent", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "OP-3.implemented", + "owner": "o4-client-status", + "status": "disabled-pending-approved-rfc-presentation-gate" + }, + { + "row": "OP-4.privacy", + "owner": "o4-client-status", + "status": "covered" + } + ], + "j3c_rows": [ + "J3C-STATUS-RELAY-SIGNER", + "J3C-STATUS-EXACT-SCOPE", + "J3C-STATUS-FRESHNESS", + "J3C-STATUS-REVISION-FOLD", + "J3C-STATUS-WITHDRAWAL", + "J3C-STATUS-PRIVACY", + "J3C-STATUS-VERIFY-ONLY", + "J3C-STATUS-DEDICATED-TRANSPORT", + "J3C-STATUS-REAL-USER-HIDDEN" + ], + "forbidden_public_fields": [ + "iss", + "sub", + "display_name", + "bearer_assertion", + "private_audience", + "provider_tenant_url", + "corporate_history", + "historical_label", + "employment_history" + ] +} diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs new file mode 100644 index 0000000000..bb5ce7762f --- /dev/null +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -0,0 +1,381 @@ +//! Structural O4 conformance checks for the disabled client-status surface. + +use std::fs; +use std::path::{Path, PathBuf}; + +const FIXTURE: &str = include_str!("fixtures/nip_fi_trusted_proxy.json"); +const STATUS_MODULE: &str = include_str!("../src/authorization_runtime/status.rs"); +const ASSERTION_VERIFIER: &str = include_str!("../src/corporate_identity.rs"); +const INVALIDATION_RUNTIME: &str = include_str!("../src/authorization_runtime/invalidation.rs"); +const TRANSPORT_RUNTIME: &str = include_str!("../src/authorization_runtime/transport.rs"); +const FINALIZATION_RUNTIME: &str = include_str!("../src/authorization_runtime/finalization.rs"); +const PRODUCTION_RUNTIME: &str = include_str!("../src/authorization_runtime/production.rs"); +const AUTH_HANDLER: &str = include_str!("../src/handlers/auth.rs"); +const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); +const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); + +#[test] +fn mandatory_o4_security_contracts_are_present() { + let cases = [ + ( + "protected-header-denial", + ASSERTION_VERIFIER.contains("headers.get_all(config.jwt_header.as_str())") + && ASSERTION_VERIFIER.contains("values.next().is_some()") + && ASSERTION_VERIFIER.contains("raw.contains(',')"), + ), + ( + "bounded-known-key-jwks-degradation", + ASSERTION_VERIFIER.contains("JWKS_CACHE_MAX_AGE") + && ASSERTION_VERIFIER.contains("buzz_jwks_stale_key_uses_total") + && ASSERTION_VERIFIER.contains("buzz_jwks_unknown_kid_total") + && ASSERTION_VERIFIER.contains("buzz_jwt_verification_errors_total"), + ), + ( + "lease-expiry-and-invalidation", + TRANSPORT_RUNTIME.contains("expiry_delay") + && INVALIDATION_RUNTIME.contains("cancel_invalid(community_id)"), + ), + ( + "revocation-enforcement-timing", + INVALIDATION_RUNTIME.contains("buzz_authorization_revocation_to_enforcement_seconds"), + ), + ( + "client-status-fail-closed-degradation", + AUTH_HANDLER.contains("client_status_unavailable") + && AUTH_HANDLER.contains("buzz_client_status_degradation_total"), + ), + ( + "deny-protected-request-renewal-and-session-eviction", + FINALIZATION_RUNTIME.contains("DenyProtected") + && PRODUCTION_RUNTIME + .contains("\"deny_protected\" => AuthorizationMode::DenyProtected") + && TRANSPORT_RUNTIME.contains("AuthorizationMode::DenyProtected =>") + && TRANSPORT_RUNTIME.contains("deny_protected_request(request)") + && TRANSPORT_RUNTIME.contains("request.cancellation()") + && TRANSPORT_RUNTIME.contains("cancellation.cancel()") + && TRANSPORT_RUNTIME.contains("ProtectedTransportError::DenyProtected"), + ), + ]; + + for (name, present) in cases { + assert!(present, "missing mandatory O4 security contract: {name}"); + } + + for source in [ASSERTION_VERIFIER, INVALIDATION_RUNTIME, AUTH_HANDLER] { + let observability = source + .lines() + .filter(|line| line.contains("metrics::")) + .collect::>() + .join("\n"); + for forbidden in [ + "identity_token =", + "access_token =", + "refresh_token =", + "uid =", + "subject =", + "kid =", + ] { + assert!( + !observability.contains(forbidden), + "security observability gained a private label: {forbidden}" + ); + } + } +} + +#[test] +fn synthetic_fixture_names_required_nip_fi_and_j3c_rows() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses"); + assert_eq!(fixture["fixture_classification"], "synthetic-only"); + assert_eq!(fixture["full_stack_conformance_claim"], false); + assert_eq!(fixture["presentation_gate"], "disabled"); + + for row in [ + "TR-1.direct-bypass", + "TR-1.inbound-header-copy", + "TR-1.complete-deployment-evidence", + "AS-3.future-iat", + "BD-1.cross-domain", + "SE-4.invalidation", + "DG-3.no-finite-bound", + "OP-2.discovery", + "OP-3.absent", + "OP-3.implemented", + "OP-4.privacy", + "J3C-STATUS-RELAY-SIGNER", + "J3C-STATUS-EXACT-SCOPE", + "J3C-STATUS-FRESHNESS", + "J3C-STATUS-REVISION-FOLD", + "J3C-STATUS-WITHDRAWAL", + "J3C-STATUS-PRIVACY", + "J3C-STATUS-VERIFY-ONLY", + "J3C-STATUS-DEDICATED-TRANSPORT", + "J3C-STATUS-REAL-USER-HIDDEN", + ] { + assert!(FIXTURE.contains(row), "fixture omitted required row {row}"); + } + + let future_iat = fixture["nip_fi_rows"] + .as_array() + .expect("NIP-FI rows are an array") + .iter() + .find(|row| row["row"] == "AS-3.future-iat") + .expect("future-iat allocation exists"); + assert_eq!(future_iat["owner"], "o4-client-status"); + assert_eq!(future_iat["status"], "covered"); + + let projection = fixture["nip_fi_rows"] + .as_array() + .expect("NIP-FI rows are an array") + .iter() + .find(|row| row["row"] == "OP-3.implemented") + .expect("implemented projection allocation exists"); + assert_eq!( + projection["status"], + "disabled-pending-approved-rfc-presentation-gate" + ); +} + +#[test] +fn optional_iat_uses_the_shared_injected_authorization_clock() { + assert!(ASSERTION_VERIFIER.contains("self.authorization_clock.now()?")); + assert!(ASSERTION_VERIFIER.contains("validate_optional_iat(")); + assert!( + !ASSERTION_VERIFIER + .contains("validate_optional_iat(&decoded.claims.claims, Timestamp::now().as_secs())"), + "optional iat must not bypass the injected authorization clock" + ); +} + +#[test] +fn client_authored_status_is_rejected_by_the_central_relay_only_fence() { + let relay_only_predicate = KIND_REGISTRY + .split("pub const fn is_relay_only_kind") + .nth(1) + .and_then(|suffix| suffix.split("/// Extract the kind").next()) + .expect("central relay-only predicate exists"); + assert!(relay_only_predicate.contains("KIND_CLIENT_BINDING_STATUS")); + assert!(INGEST_HANDLER.contains("buzz_core::kind::is_relay_only_kind(kind_u32)")); + assert!(INGEST_HANDLER.contains("restricted: relay-only kind")); +} + +#[test] +fn public_projection_retirement_is_durable_internal_and_not_operator_wired() { + let production = ASSERTION_VERIFIER + .split("#[cfg(test)]") + .next() + .expect("production assertion verifier exists"); + let migration = + include_str!("../../../migrations/0044_identity_public_projection_retirement.sql"); + assert!(migration.contains("identity_public_projection_retirements")); + assert!(migration.contains("source_binding_id")); + assert!(migration.contains("source_binding_version")); + for private in [ + "issuer TEXT", + "uid", + "display_name", + "actor", + "reason", + "identity_token", + "access_token", + "refresh_token", + ] { + assert!( + !migration.contains(private), + "projection retirement state gained private field {private}" + ); + } + assert!( + production.contains("begin_active_public_projection"), + "active projection publication must revalidate the exact binding at its database boundary" + ); + let production_runtime = include_str!("../src/authorization_runtime/production.rs"); + assert!( + production_runtime.contains("reconcile_public_projection_retirements_startup"), + "committed lifecycle retirement must be reconciled before protected runtime installation" + ); + assert!( + production_runtime.contains("run_public_projection_retirement_reconciliation"), + "committed lifecycle retirement needs durable periodic/restart reconciliation" + ); + for operator_surface in [ + include_str!("../src/api/operator.rs"), + include_str!("../src/api/bridge.rs"), + ] { + assert!(!operator_surface.contains("PublicProjectionRetirement")); + } +} + +#[test] +fn trusted_proxy_fixture_fails_closed_without_both_deployment_controls() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses"); + let cases = fixture["trusted_proxy_cases"] + .as_array() + .expect("trusted proxy cases are an array"); + assert_eq!(cases.len(), 3); + + for case in cases { + let isolation = case["origin_isolation_enforced"] + .as_bool() + .expect("fixture isolation flag is boolean"); + let stripping = case["inbound_assertion_header_stripped"] + .as_bool() + .expect("fixture stripping flag is boolean"); + let expected = case["expected"] + .as_str() + .expect("fixture expectation is a string"); + if isolation && stripping { + assert_eq!(expected, "eligible-for-provider-verification"); + } else { + assert_eq!(expected, "deny-before-verification"); + } + } +} + +#[test] +fn verification_only_adapter_has_no_authority_storage_or_pubsub_dependency() { + let production = STATUS_MODULE + .split("#[cfg(test)]") + .next() + .expect("production status module exists"); + for forbidden in [ + "AuthContext", + "AuthorizationLease", + "CapabilitySet", + "AuthState", + "buzz_db", + "buzz_pubsub", + "publish_event", + "store_event", + "KIND_USER_TRUSTED_ASSERTION", + "corporate_identity", + ] { + assert!( + !production.contains(forbidden), + "verification-only status gained forbidden authority path {forbidden}" + ); + } + + assert_eq!( + production + .matches("pub struct ClientStatusPresentationPermit {") + .count(), + 1, + "the disabled presentation permit must have one opaque definition" + ); + assert!(production.contains("impl ClientStatusPresentationPermit")); + assert!(production.contains("pub fn from_complete_stack(")); + assert!(production.contains("reviewed_implementation_revision")); + assert!(production.contains("presentation_gate_passed")); + assert!(production.contains("dedicated_client_contract_passed")); + assert!( + !production.contains("std::env"), + "presentation must not be enabled by an environment boolean" + ); + + let current_issuance = production + .split("pub fn issue_verification_only") + .nth(1) + .and_then(|suffix| suffix.split("/// Sign a generic withdrawal").next()) + .expect("current-display issuance method exists"); + assert!( + current_issuance.contains("&ClientStatusPresentationPermit"), + "current-display signing must require the complete-stack permit" + ); + assert_eq!( + production + .matches("issue_current(&evidence, label)") + .count(), + 1, + "no second production current-display signing path may bypass the permit" + ); +} + +#[test] +fn status_uses_only_the_dedicated_authenticated_production_path() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repo = manifest + .parent() + .and_then(Path::parent) + .expect("relay crate is nested under repository crates directory"); + let roots = [ + manifest.join("src/handlers"), + manifest.join("src/api"), + manifest.join("src/main.rs"), + manifest.join("src/router.rs"), + manifest.join("src/subscription.rs"), + manifest.join("src/connection.rs"), + manifest.join("src/protocol.rs"), + repo.join("desktop/src"), + repo.join("desktop/src-tauri/src"), + repo.join("mobile/lib"), + repo.join("web/src"), + ]; + + for root in roots { + for file in source_files(&root) { + if file.ends_with("src/handlers/auth.rs") { + continue; + } + let source = fs::read_to_string(&file).expect("source file is readable"); + for forbidden in [ + "KIND_CLIENT_BINDING_STATUS", + "ClientBindingStatus", + "client_binding_status", + "24244", + "deliver_verification_only", + ] { + assert!( + !source.contains(forbidden), + "{} exposes status through an ordinary route {forbidden}", + file.display() + ); + } + } + } + + let handler = fs::read_to_string(manifest.join("src/handlers/auth.rs")) + .expect("AUTH handler source is readable"); + let state = fs::read_to_string(manifest.join("src/state.rs")) + .expect("application state source is readable"); + let status = fs::read_to_string(manifest.join("src/authorization_runtime/status.rs")) + .expect("status runtime source is readable"); + let nip11 = + fs::read_to_string(manifest.join("src/nip11.rs")).expect("NIP-11 source is readable"); + let router = + fs::read_to_string(manifest.join("src/router.rs")).expect("router source is readable"); + assert!(handler.contains("present_after_auth")); + assert!(state.contains("install_client_status_runtime")); + assert!(state.contains("install_nip_fi_discovery")); + assert!(status.contains("from_complete_stack")); + assert!(status.contains("__buzz_client_binding_status_v1__")); + assert!(nip11.contains("state.nip_fi_discovery()")); + assert!(nip11.contains("with_conformant_federated_identity")); + assert!(router.contains("nip11_document(&state, raw_host).await")); + assert!(!status.contains("std::env")); +} + +fn source_files(root: &Path) -> Vec { + if root.is_file() { + return vec![root.to_path_buf()]; + } + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).expect("source directory is readable") { + let path = entry.expect("source directory entry is readable").path(); + if path.is_dir() { + pending.push(path); + } else if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!(extension, "rs" | "ts" | "tsx" | "js" | "jsx" | "dart") + }) + { + files.push(path); + } + } + } + files +} diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e..6fe2c44763 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -526,6 +526,16 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + // This is the final common boundary for every action, including effects + // that do not use the relay ActionSink. A delayed run must therefore pass + // the embedding relay's current mutation gate again immediately before + // SendMessage, AddReaction, CallWebhook, or any future action dispatch. + if matches!(action, CallWebhook { .. }) { + engine.require_outbound_webhook(community_id)?; + } else { + engine.require_mutation(community_id)?; + } + match action { SendMessage { text, channel } => { // Look up workflow metadata for destination validation and @@ -982,6 +992,10 @@ pub async fn execute_run( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + engine .db .update_workflow_run( @@ -1032,6 +1046,10 @@ pub async fn execute_from_step( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + // Mark run as Running now that we have a permit (resume from approval). // Preserve the existing execution trace from pre-approval steps. let existing_trace = match engine.db.get_workflow_run(community_id, run_id).await { diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..d17e7c3f05 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -33,11 +33,13 @@ pub mod action_sink; pub mod error; pub mod executor; +pub mod mutation_gate; pub mod schema; pub use action_sink::{ActionSink, ActionSinkError}; pub use error::{PartialProgress, WorkflowError}; pub use executor::ExecutionResult; +pub use mutation_gate::MutationGate; pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef}; use std::collections::HashMap; @@ -87,6 +89,9 @@ pub struct WorkflowEngine { /// Action sink for executing side-effects (SendMessage, etc.). /// Late-initialized via [`set_action_sink`] after `AppState` construction. pub(crate) action_sink: OnceLock>, + /// Provider-neutral gate evaluated before every mutation or external effect. + /// Late-initialized by the embedding relay after `AppState` construction. + pub(crate) mutation_gate: OnceLock>, /// Short-TTL cache for the per-event enabled-workflow lookup, keyed /// `(community_id, channel_id)`. Most channels have no workflows, so this /// removes one SELECT from nearly every ingested event. @@ -115,6 +120,7 @@ impl WorkflowEngine { run_semaphore, last_fired: DashMap::new(), action_sink: OnceLock::new(), + mutation_gate: OnceLock::new(), workflow_cache: moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) @@ -180,6 +186,38 @@ impl WorkflowEngine { } } + /// Set the workflow mutation gate. Called once by an embedding relay. + /// + /// # Panics + /// Panics if called more than once. + pub fn set_mutation_gate(&self, gate: Arc) { + if self.mutation_gate.set(gate).is_err() { + panic!("mutation_gate already initialized"); + } + } + + /// Require current authority before a workflow mutation or external effect. + /// + /// A standalone engine with no installed gate preserves legacy behavior. + /// Once an embedding relay installs a gate, every engine-owned mutation + /// door calls this method before touching durable or external state. + pub(crate) fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + mutation_gate::require_configured_mutation( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + + pub(crate) fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), WorkflowError> { + mutation_gate::require_configured_outbound_webhook( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + /// Get the action sink reference. /// /// Returns `Err(WorkflowError)` if the sink has not been initialized via @@ -217,6 +255,13 @@ impl WorkflowEngine { result: Result, existing_trace: Option>, ) { + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + run_id = %run_id, + "Skipping workflow finalization because mutation authority is unavailable: {error}" + ); + return; + } let prefix = existing_trace.unwrap_or_default(); match result { @@ -395,6 +440,14 @@ impl WorkflowEngine { continue; } + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + workflow_id = %workflow.id, + "Skipping workflow because mutation authority is unavailable: {error}" + ); + continue; + } + let trigger_event_id_bytes = event.event.id.as_bytes().to_vec(); let run_id = match self .db @@ -608,6 +661,14 @@ impl WorkflowEngine { continue; } + if let Err(error) = self.require_mutation(community_id) { + tracing::warn!( + workflow_id = %workflow.id, + "Cron tick: skipping workflow because mutation authority is unavailable: {error}" + ); + continue; + } + // Durable at-most-once claim — the cross-pod fire boundary. // The loser receives `None` and skips BEFORE any run creation or // side effect. `community_id` is the workflow row's own diff --git a/crates/buzz-workflow/src/mutation_gate.rs b/crates/buzz-workflow/src/mutation_gate.rs new file mode 100644 index 0000000000..bed8666143 --- /dev/null +++ b/crates/buzz-workflow/src/mutation_gate.rs @@ -0,0 +1,77 @@ +//! Provider-neutral admission gate for workflow mutations and external effects. + +use buzz_core::tenant::CommunityId; + +use crate::WorkflowError; + +/// Server-owned gate evaluated before every workflow mutation or external effect. +/// +/// The workflow engine deliberately knows nothing about identity providers, +/// leases, or deployment configuration. A relay can install a gate that denies +/// an authorization domain until it has a transaction-owning executor. When no +/// gate is installed, the standalone engine preserves its legacy behavior. +pub trait MutationGate: Send + Sync { + /// Require current authority for one server-resolved authorization domain. + fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError>; + + /// Require authority for an outbound network effect. + fn require_outbound_webhook(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + self.require_mutation(community_id) + } +} + +pub(crate) fn require_configured_mutation( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_mutation(community_id), + None => Ok(()), + } +} + +pub(crate) fn require_configured_outbound_webhook( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_outbound_webhook(community_id), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + struct DenyGate(AtomicUsize); + + impl MutationGate for DenyGate { + fn require_mutation(&self, _community_id: CommunityId) -> Result<(), WorkflowError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(WorkflowError::Unauthorized( + "synthetic mutation denial".into(), + )) + } + } + + #[test] + fn absent_gate_preserves_legacy_and_configured_denial_is_authoritative() { + let community_id = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + assert!(require_configured_mutation(None, community_id).is_ok()); + + let gate = DenyGate(AtomicUsize::new(0)); + assert!(matches!( + require_configured_mutation(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 1); + assert!(matches!( + require_configured_outbound_webhook(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 2); + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9feecbee01..476736f028 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1013,6 +1013,24 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-auth" +version = "0.1.0" +dependencies = [ + "buzz-core", + "hex", + "nostr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1130,6 +1148,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-auth", "buzz-core", "bytes", "chrono", diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index c870c8f280..37ae3e41c5 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -89,33 +89,56 @@ fn verified_identities( let parts = tag.as_slice(); (parts.len() == 2).then(|| parts[1].as_str()) }; + let canonical_tag_set = |active: bool| { + let allowed: &[&str] = if active { + &["d", "p", "verified", "active", "expiration", "display_name"] + } else { + &["d", "p", "verified", "active", "expiration"] + }; + event.tags.len() == allowed.len() + && event.tags.iter().all(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 + && allowed.contains(&parts[0].as_str()) + && event + .tags + .iter() + .filter(|candidate| candidate.as_slice().first() == parts.first()) + .count() + == 1 + }) + }; // Select the signed replaceable-event head before validating its // payload. Otherwise a newer malformed assertion could be skipped and // silently resurrect the older active label returned alongside it. - let identity = match (tag_value("d"), tag_value("verified"), tag_value("p")) { - (Some(assertion_d), Some("relay"), Some(asserted_subject)) - if assertion_d == subject && asserted_subject == subject => - { - match tag_value("active") { - Some("false") => None, - Some("true") => match ( - tag_value("expiration") - .and_then(|value| value.parse::().ok()) - .filter(|expiration| *expiration > now), - tag_value("display_name") - .map(str::trim) - .filter(|value| !value.is_empty()), - ) { - (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { - display_name: display_name.to_string(), - expires_at, - }), + let identity = if event.content.is_empty() { + match (tag_value("d"), tag_value("verified"), tag_value("p")) { + (Some(assertion_d), Some("relay"), Some(asserted_subject)) + if assertion_d == subject && asserted_subject == subject => + { + match tag_value("active") { + Some("false") if canonical_tag_set(false) => None, + Some("true") if canonical_tag_set(true) => match ( + tag_value("expiration") + .and_then(|value| value.parse::().ok()) + .filter(|expiration| *expiration > now), + tag_value("display_name") + .map(str::trim) + .filter(|value| !value.is_empty()), + ) { + (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { + display_name: display_name.to_string(), + expires_at, + }), + _ => None, + }, _ => None, - }, - _ => None, + } } + _ => None, } - _ => None, + } else { + None }; let created_at = event.created_at.as_secs(); let event_id = event.id.to_hex(); @@ -650,6 +673,85 @@ mod tests { ); } + #[test] + fn newer_nonempty_projection_removes_verified_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let canonical_tags = || { + [ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ] + }; + let active = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags(canonical_tags()) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let nonempty = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "private content must never be projected", + ) + .tags(canonical_tags()) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + + assert!( + verified_identities(&[active, nonempty], Some(&relay.public_key().to_hex())).is_empty(), + "a malformed newer head must withdraw rather than reveal or resurrect a label" + ); + } + + #[test] + fn newer_projection_with_unknown_or_duplicate_tags_removes_verified_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let canonical = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + + for extra in [ + nostr::Tag::parse(["issuer", "private.invalid"]).unwrap(), + nostr::Tag::parse(["display_name", "Replacement"]).unwrap(), + ] { + let mut tags = canonical.tags.clone().to_vec(); + tags.push(extra); + let malformed = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "", + ) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + assert!(verified_identities( + &[canonical.clone(), malformed], + Some(&relay.public_key().to_hex()) + ) + .is_empty()); + } + } + #[test] fn newer_malformed_assertion_does_not_resurrect_older_identity() { let relay = nostr::Keys::generate(); diff --git a/docs/NIP_FI_RUNTIME_OPERATIONS.md b/docs/NIP_FI_RUNTIME_OPERATIONS.md new file mode 100644 index 0000000000..f9f1a1fd57 --- /dev/null +++ b/docs/NIP_FI_RUNTIME_OPERATIONS.md @@ -0,0 +1,152 @@ +# NIP-FI runtime operations + +This runbook covers provider-neutral NIP-FI session/discovery behavior and the +separate disabled relay-authenticated client-status contract. It does not +authorize enabling a provider, a client presentation surface, or a conformance +claim. + +## Session and reconnect behavior + +For WebSocket authorization, the assertion belongs on the upgrade request and +fresh NIP-42 proof follows on that connection. A direct lease ends at the +earliest assertion, binding, policy, or implementation bound. Base V1 has no +in-connection assertion renewal: expiry requires a new connection, a fresh +upgrade assertion, and fresh NIP-42 proof. + +Delegated sessions require a separately validated delegation, an active owner +binding, and a positive finite configured implementation maximum. A cached +owner lease is not substitute authority. Reconnect requires fresh delegate +proof and revalidation of every dependency. + +When an observed binding, identity, key, policy, or delegation dependency +becomes invalid, reject protected operations or close the affected connection +within the documented detection bound. A polling deployment must publish its +maximum detection latency and must not claim immediate revocation. + +The optional assertion `iat` check uses the shared injected authorization +clock. The current JWT library still evaluates `exp` and `nbf` with its own +system-clock source, so operators must maintain host clock synchronization and +must not claim fully centralized assertion time until that library boundary is +made injectable. + +`iat` is optional in Base V1. When present, a malformed or more-than-60-second +future value is rejected. A `kid` absent from a still-fresh JWKS set is denied +without an immediate refetch; the default set lifetime is 300 seconds. Issuers +must overlap old and new signing keys for at least the cache lifetime plus the +documented clock allowance. Refresh after expiry is single-flight, and refresh +or issuer failure never falls back to an unverified key. + +The assertion header is singular. Multiple field lines, a comma-combined +value, invalid UTF-8, or an empty value is denied before verification. A +trusted-proxy adapter must prove origin isolation and that it stripped every +inbound copy before injecting exactly one assertion; merely observing the +configured header is not transport provenance. + +Protected media downloads include both `GET` and `HEAD`. An enforcing domain +cannot expose either method without current authority; a deployment that wants +public media needs a separately reviewed public-media policy rather than an +implicit read bypass. + +Client status is presentation-only. It expires independently of an +authorization lease and is cleared on expiry, disconnect, relay-key change, +domain change, or author change. A status cannot renew a session, authorize an +operation, create a binding, mint a lease, or mutate membership. + +## Upgrade sequence + +1. Upgrade and reconcile durable authorization, binding, lifecycle, lease, and + status-revision-floor state before enabling any behavior. +2. Deploy servers with NIP-FI discovery absent and the client-status + presentation gate disabled. +3. Run every applicable NIP-FI row against the exact candidate revision. For + `trusted-proxy`, attach enforced origin-isolation evidence plus negative + direct-bypass and inbound-header-copy tests. +4. Confirm mixed-version servers all omit discovery. Never advertise based on + a per-process flag or a partial fleet. +5. Supply a complete-stack conformance input only after the whole serving fleet + runs the reviewed revision and all applicable rows pass. +6. Supply the typed client-presentation approval only after its deployment, + privacy, and client-compatibility gates pass at one exact revision. The + stock binary has no environment or boolean shortcut; without that injected + proof it cannot construct the presentation permit or install the dedicated + exact-connection transport. + +Old clients ignore unknown status events, and old servers emit none. New +clients must default to no indicator when status is absent, invalid, expired, +withheld, or unsupported. NIP-FI authorization behavior must remain identical +whether client presentation code is present or absent. + +## Rollback + +Remove the complete-stack readiness input before or with the first server +rollback so NIP-11 immediately omits NIP-FI discovery. Do not leave discovery +enabled for a mixed or unreviewed fleet. + +Client-status rollback requires no authority migration: the events are +ephemeral and display-only. Disconnect affected clients or wait no longer than +the bounded status lifetime; clients clear on either condition. Never translate +a cached status into an authorization decision during rollback. + +Preserve durable authorization and lifecycle state. Preserve and reconcile the +status revision floor so a restored older process cannot emit a lower revision +that a client might mistake for current state. If that state is unavailable, +emit no status. + +## Public projection retirement + +The privacy-approved NIP-85 label projection is optional and never authority. +After an authoritative revoke or rotate commits, the lifecycle integration must +derive internal retirement work from the committed lifecycle record. The work +contains only the server-resolved domain, old public Nostr key, relay author, +operation identifier, and opaque binding generation. It must not contain +issuer, subject, display label, provider claims, actor, or free-text reason. +The reconciler idempotently replaces an active projection with the existing +inactive, label-free parameterized event. + +A read, clock, build, or write failure must not roll back the already committed +lifecycle mutation. Retry the same domain/key request. If a write committed but +its acknowledgement was lost, the retry observes the inactive replacement and +terminates without another write. + +The runtime materializes committed revoke/rotate operations into an internal +durable queue, fences active publication and retirement with the exact binding +generation, and drains unfinished projection and delivery work before protected +runtime installation and after restart. Periodic discovery is the crash-window +backstop. The active projection TTL remains defense in depth. Authenticated +lifecycle routes and durable operator audit remain separately owned. + +## Backup and restore + +Back up authoritative binding/lifecycle state, policy state, cryptographic +secrets required by deployment policy, and durable status revision/floor state +using the owning subsystem's procedure. Protect the dedicated client-status +privacy key as a secret and never reuse it across unrelated deployments. + +Do not back up or restore: + +- authorization or provider caches; +- direct or delegated leases; +- WebSocket connection state; +- client presentation caches; +- emitted client-status events; or +- ordinary event/pubsub copies of client status, because none may exist. + +After restore, start with discovery absent and presentation disabled. Rebuild +authorization decisions from authoritative state, reconcile revision floors, +reconcile committed projection retirements, and reconnect clients with fresh +assertions/proofs. If the relay signing key or client-status privacy key +changed, treat every old presentation as invalid. A restored service must +complete same-revision conformance again before discovery can return. + +## Privacy and observability + +Logs, metrics, traces, fixtures, and NIP-11 output must not contain raw bearer +assertions or unredacted issuer, subject, audience, tenant URL, claim name, +display name, email, or provider-private metadata. Use bounded categorical +failure classes and pseudonymous correlation where necessary. + +Alert on aggregate validation failures, revision-source unavailability, +dedicated-transport unavailability after a future gate is approved, and +dependency-invalidation lag. Do not include the rejected private value in an +alert. The presence or absence of a client status is not evidence of access and +must never drive an authorization SLO. diff --git a/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md new file mode 100644 index 0000000000..0995f9eb57 --- /dev/null +++ b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md @@ -0,0 +1,166 @@ +# NIP-FI runtime conformance and client-status boundary + +This document maps Buzz runtime evidence to the normative +[NIP-FI specification](NIP-FI.md), [formal model](NIP-FI-MODEL.md), and +[conformance matrix](NIP-FI-CONFORMANCE.md). It does not make a conformance +claim. Discovery remains absent until an injected report proves that every +applicable row passed against one reviewed implementation revision. + +## Discovery gate + +`RelayInfo::build` omits both `limitation.federated_identity` and the top-level +`federated_identity` object. `ConformanceReadyNipFiDiscovery` is the only API +that can add them. It requires all of the following: + +- a provider-neutral discovery value with at least one unique supported + transport and exactly one enrollment mode; +- a positive finite delegated-lease maximum whenever delegation is advertised; +- an exact 40-character reviewed Git revision; +- an injected complete-stack result asserting that every applicable row passed + at that same revision; and +- for `trusted-proxy`, deployment evidence for both origin isolation and + stripping untrusted inbound assertion-header copies. + +The reviewed revision and deployment evidence are gate inputs, not public +metadata. NIP-11 exposes no issuer URL, tenant URL, claim name, subject, +audience, assertion header name, or provisional NIP number. Unsupported +behavior is omitted rather than advertised as partially implemented. + +The assertion header is singular at every ingress. Repeated field lines, +comma-combined values, invalid UTF-8, and empty values fail closed. An installed +adapter must supply verified transport provenance; header presence alone is +never classified as `trusted-proxy`. Protected media `GET` and `HEAD` remain +inside the enforcing transport inventory unless a separate reviewed public +media policy is selected. + +The optional assertion `iat` check uses the shared injected authorization +clock and accepts a missing claim. A present value must be an unsigned integer +no later than injected verifier time plus the bounded 60-second skew. The +current `jsonwebtoken` dependency still evaluates `exp` and `nbf` against its +own system-clock source. Therefore `AS-3.future-iat` is covered, but this +candidate does not claim that all assertion-time checks use one injected clock; +that inherited limitation remains part of the full-stack review. + +## Relay-authenticated client status + +Kind `24244` is a Buzz-local, short-lived presentation contract, not NIP-FI +authorization evidence or a NIP-FI conformance surface. A status is signed by +the trusted relay and scoped to an exact server-resolved authorization domain +and event-author key. A current status contains a binding version, a +privacy-keyed policy revision, a monotonic durable status revision, and a +bounded validity window. A withdrawal contains only its exact scope, revision, +and bounded validity window. The two wire states are: + +- `display_current`; or +- `withdrawn`, with no lifecycle cause or historical binding fields. + +Clients fold only within one trusted relay/domain/author scope. A lower +revision is rejected. An equal revision is idempotent only for the identical +signed event; a conflicting equal revision is rejected. Expiry, disconnect, +relay-key change, authorization-domain change, or event-author change clears +presentation. Revision high-water state may survive a transient disconnect, +but it is never authority. + +The relay adapter is one-way from `VerificationOnlyDisposition` to a signed +event. It has no dependency on authorization leases, membership mutation, +event ingest, persistence, subscriptions, pub/sub, ordinary delivery, or +NIP-85. The production seam targets an exact authenticated connection and can +construct its permit only from typed evidence that the RFC presentation, +privacy, and dedicated-client gates passed at one exact reviewed revision. The +stock binary supplies no such evidence, key, or transport, so status remains +disabled by default. + +The optional label constructor accepts only privacy-approved server +configuration. There is no constructor from issuer data, subject data, +`display_name`, mutable profile content, or provider decisions. The policy +revision is a length-framed, domain-separated HMAC under an injected dedicated +client-status privacy key; identical provider values are unlinkable under +distinct keys. + +## Stable row allocation + +The synthetic fixture is +`crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json`. It contains no +production issuer, subject, domain, key, assertion, or tenant data. + +| Row | Evidence in this lane | Full-stack state | +|---|---|---| +| `TR-1.direct-bypass` | Negative origin-isolation fixture | Deployment proof still required | +| `TR-1.inbound-header-copy` | Negative header-copy fixture | Deployment proof still required | +| `TR-1.complete-deployment-evidence` | Positive two-control fixture shape | Real enforced-control evidence still required | +| `AS-3.future-iat` | Optional assertion `iat` accepts absence and bounded skew; malformed or farther-future values fail closed. Status also rejects future issue time | Covered by O4 | +| `BD-1.cross-domain` | Status validation and folding reject cross-domain scope | Authorization-runtime row must pass at the reviewed revision | +| `SE-4.invalidation` | Withdrawal and client clearing are covered | Lease invalidation runtime must pass at the reviewed revision | +| `DG-3.no-finite-bound` | Discovery cannot represent delegation without a positive bound | Delegation authorization must pass at the reviewed revision | +| `OP-2.discovery` | Default omission, provider-neutral fields, and complete-stack gate | Covered here; final claim still requires all rows | +| `OP-3.absent` | No real-user route or ordinary delivery path | Covered | +| `OP-3.implemented` | Dedicated exact-connection production seam exists behind typed complete-stack approval | Disabled unless the approval, privacy key, transport, and runtime are explicitly installed | +| `OP-4.privacy` | Keyed revision, bounded configured label, field/source scans | Covered | + +The local client-status rows are: + +- `J3C-STATUS-RELAY-SIGNER` +- `J3C-STATUS-EXACT-SCOPE` +- `J3C-STATUS-FRESHNESS` +- `J3C-STATUS-REVISION-FOLD` +- `J3C-STATUS-WITHDRAWAL` +- `J3C-STATUS-PRIVACY` +- `J3C-STATUS-VERIFY-ONLY` +- `J3C-STATUS-DEDICATED-TRANSPORT` +- `J3C-STATUS-REAL-USER-HIDDEN` + +These J3C rows test presentation safety only. They cannot substitute for any +NIP-FI authorization, lifecycle, session, delegation, or deployment row. + +## Public projection retirement join + +The existing opt-in NIP-85 label projection is separate from both NIP-FI +authorization and kind `24244` client status. Its active assertion is TTL +bounded, but a committed revoke or rotate also needs an inactive parameterized +replacement for the old public key. + +The public-projection retirement reconciler is a provider-neutral post-commit +seam. It derives private retry work from committed lifecycle rows and persists +only public event coordinates plus opaque binding generations. The relay reads +the exact relay-authored projection and, when active, writes the existing +`active=false`, `expiration=0`, label-free replacement. A missing or already +inactive projection is an idempotent terminal result. Store or clock failure +leaves both lifecycle authority and the active projection unchanged while the +work remains retryable. + +Active publication and retirement share the identity-key and parameterized +event commit boundaries. Server-only head metadata prevents a delayed rotation +job from retiring a later legitimate use of the same key. Startup and periodic +reconciliation provide restart recovery and Redis/local delivery retry. This +lane does not add authenticated lifecycle endpoints or durable operator audit. + +## Compatibility cases + +| Case | Required result | +|---|---| +| Old relay, new client | No discovery or status; client shows no indicator | +| New relay, old client | Unknown ephemeral status is ignored; ordinary event behavior is unchanged | +| Mixed relay fleet before complete conformance | Discovery stays absent; presentation stays disabled | +| Stale client cache | Expired status is cleared; lower or conflicting revisions cannot restore it | +| Spoofed user event | Wrong signer, kind, tags, content, or signature is rejected | +| Cross-domain replay | Exact expected domain and author mismatch is rejected; scope change clears state | +| Relay signing-key rotation | Old presentation is cleared and the new relay key must be trusted independently | +| Privacy-key rotation | Policy revision changes; it grants no authority and clients accept it only at a higher durable status revision | +| Provider or lifecycle outage | Relay issues an opaque `withdrawn` status only with authoritative revision evidence, otherwise emits nothing | +| Gate disabled | No presentation runtime is installed; no real-user status is delivered | + +## Mechanical checks + +Run from the repository root in the Hermit environment: + +```sh +cargo test -p buzz-core client_binding_status +cargo test -p buzz-relay authorization_runtime::status +cargo test -p buzz-relay nip11 +cargo test -p buzz-relay --test nip_fi_runtime_conformance +``` + +The integration test scans ordinary relay ingest, API, router, state, +subscription, connection, and protocol sources, plus desktop, mobile, and web +client sources. Any reference to the status kind, contract, or disabled +delivery method fails the test. diff --git a/migrations/0030_authorization_invalidation_floors.sql b/migrations/0030_authorization_invalidation_floors.sql new file mode 100644 index 0000000000..115788112b --- /dev/null +++ b/migrations/0030_authorization_invalidation_floors.sql @@ -0,0 +1,47 @@ +-- Durable, provider-neutral authorization invalidation authority. +-- +-- Generations are allocated under a per-community row lock. Receipts make +-- retries idempotent, while selector floors retain the strongest committed +-- fail-closed effect. Redis fan-out is only a hint to read these tables. + +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 TABLE authorization_invalidation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, generation) +); + +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind TEXT NOT NULL CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner' + )), + selector_fingerprint BYTEA NOT NULL CHECK (length(selector_fingerprint) = 32), + generation BIGINT NOT NULL CHECK (generation > 0), + sticky_deny BOOLEAN NOT NULL DEFAULT FALSE, + binding_version_floor BIGINT CHECK (binding_version_floor > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, generation) + REFERENCES authorization_invalidation_receipts (community_id, generation), + CHECK ((selector_kind = 'binding') = (binding_version_floor IS NOT NULL)) +); + +CREATE INDEX idx_authorization_invalidation_floors_generation + ON authorization_invalidation_floors (community_id, generation); diff --git a/migrations/0031_authorization_operation_receipts.sql b/migrations/0031_authorization_operation_receipts.sql new file mode 100644 index 0000000000..ddb4b40c12 --- /dev/null +++ b/migrations/0031_authorization_operation_receipts.sql @@ -0,0 +1,42 @@ +-- Transaction-owned protected-operation idempotency. +-- +-- These receipts are part of the mutation commit protocol. They are not an +-- authorization decision log or operator audit trail: a receipt exists only +-- when the protected mutation committed in the same transaction. + +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL CHECK ( + length(operation_kind) > 0 AND length(operation_kind) <= 128 + ), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + result_version SMALLINT NOT NULL DEFAULT 1 CHECK (result_version > 0), + result_payload BYTEA NOT NULL CHECK (octet_length(result_payload) <= 65536), + lease_expires_at TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, operation_id) +); + +CREATE INDEX idx_authorization_operation_receipts_committed_at + ON authorization_operation_receipts (community_id, committed_at); + +-- The transaction rechecks expiry before inserting its receipt, and this +-- deferred trigger closes the final interval between that check and COMMIT. +CREATE FUNCTION authorization_operation_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.lease_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'protected operation authorization expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operation_expiry + AFTER INSERT OR UPDATE OF lease_expires_at + ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION authorization_operation_expiry_guard(); diff --git a/migrations/0032_protected_object_publications.sql b/migrations/0032_protected_object_publications.sql new file mode 100644 index 0000000000..99213b9342 --- /dev/null +++ b/migrations/0032_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/0033_audio_session_admissions.sql b/migrations/0033_audio_session_admissions.sql new file mode 100644 index 0000000000..6db8a8b3ad --- /dev/null +++ b/migrations/0033_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/0034_protected_object_authority.sql b/migrations/0034_protected_object_authority.sql new file mode 100644 index 0000000000..43bbbef54a --- /dev/null +++ b/migrations/0034_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/0035_git_publication_origin.sql b/migrations/0035_git_publication_origin.sql new file mode 100644 index 0000000000..885dbd7e2d --- /dev/null +++ b/migrations/0035_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/0036_audio_admission_lifecycle.sql b/migrations/0036_audio_admission_lifecycle.sql new file mode 100644 index 0000000000..68cf49bd31 --- /dev/null +++ b/migrations/0036_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/0037_authorization_authority_epochs.sql b/migrations/0037_authorization_authority_epochs.sql new file mode 100644 index 0000000000..5b2da18912 --- /dev/null +++ b/migrations/0037_authorization_authority_epochs.sql @@ -0,0 +1,244 @@ +-- Monotonic authority epoch covering every PostgreSQL-backed V1 authority +-- reduction. The epoch is transactionally advanced by table triggers, so a +-- stale restore cannot hide a principal/key/pair tombstone, membership loss, +-- invalidation, publication transition, or audio-admission transition from +-- the independent restore witness. + +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; + +-- 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 new 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 + 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', 'finished') + AND claimant_id IS NOT NULL + AND attachment_generation > 0 + AND claim_expires_at IS NOT NULL) + OR + (state = 'aborted') + ); + +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + authority_epoch BIGINT NOT NULL DEFAULT 1 CHECK (authority_epoch > 0), + status_revision BIGINT NOT NULL DEFAULT 1 CHECK (status_revision > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id) +); + +INSERT INTO authorization_authority_epochs (community_id) +SELECT id FROM communities +ON CONFLICT (community_id) DO NOTHING; + +-- Dedicated current-only client projection revision. The event-author key is +-- the presentation scope; issuer/subject and lifecycle history remain solely +-- in the identity authority tables. +CREATE TABLE client_status_revisions ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + event_author_pubkey BYTEA NOT NULL CHECK (length(event_author_pubkey) = 32), + revision BIGINT NOT NULL CHECK (revision > 0), + disposition TEXT NOT NULL CHECK (disposition IN ('current', 'withdrawn')), + binding_id UUID, + binding_version BIGINT CHECK (binding_version > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, event_author_pubkey), + CHECK ( + (disposition = 'current' AND binding_id IS NOT NULL AND binding_version IS NOT NULL) + OR + (disposition = 'withdrawn' AND binding_id IS NULL AND binding_version IS NULL) + ) +); + +-- Authorization state has no meaning after its owning community is removed. +-- Earlier additive migrations intentionally used restrictive foreign keys; +-- make teardown atomic now that the complete protected state set is known. +ALTER TABLE authorization_invalidation_domains + DROP CONSTRAINT authorization_invalidation_domains_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_domains_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_invalidation_receipts + DROP CONSTRAINT authorization_invalidation_receipts_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_receipts_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_invalidation_floors + DROP CONSTRAINT authorization_invalidation_floors_community_id_fkey, + ADD CONSTRAINT authorization_invalidation_floors_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; +ALTER TABLE authorization_operation_receipts + DROP CONSTRAINT authorization_operation_receipts_community_id_fkey, + ADD CONSTRAINT authorization_operation_receipts_community_id_fkey + FOREIGN KEY (community_id) REFERENCES communities(id) ON DELETE CASCADE; + +CREATE FUNCTION advance_authorization_authority_epoch() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + domain_id UUID; + invalidation_event UUID; + invalidation_generation BIGINT; +BEGIN + domain_id := COALESCE(NEW.community_id, OLD.community_id); + + -- The nested generation update below has its own table trigger. The outer + -- protected mutation owns the epoch advancement, so the nested trigger is + -- deliberately inert rather than double-counting one authority change. + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + -- Invalidation-domain presence is the durable marker installed during + -- protected-domain initialization. Legacy/Off communities must retain + -- byte-for-byte behavior and must not acquire authorization side effects. + IF TG_TABLE_NAME <> 'authorization_invalidation_domains' + AND NOT EXISTS ( + SELECT 1 FROM authorization_invalidation_domains + WHERE community_id = domain_id + ) + THEN + RETURN NULL; + END IF; + + -- Community teardown removes all protected state through cascading + -- foreign keys; it cannot publish a new floor for a domain that no longer + -- exists. + IF TG_TABLE_NAME = 'authorization_invalidation_domains' AND TG_OP = 'DELETE' THEN + RETURN NULL; + END IF; + + INSERT INTO authorization_authority_epochs + (community_id, authority_epoch, status_revision, updated_at) + VALUES (domain_id, 2, 2, clock_timestamp()) + ON CONFLICT (community_id) DO UPDATE + SET authority_epoch = authorization_authority_epochs.authority_epoch + 1, + status_revision = authorization_authority_epochs.status_revision + 1, + updated_at = clock_timestamp(); + + IF TG_TABLE_NAME IN ( + 'identity_bindings', + 'identity_principals', + 'identity_revoked_keys', + 'identity_retired_pairs', + 'relay_members', + 'channel_members', + 'community_bans', + 'channels', + 'users' + ) THEN + invalidation_event := gen_random_uuid(); + UPDATE authorization_invalidation_domains + SET generation = generation + 1, + updated_at = clock_timestamp() + WHERE community_id = domain_id + RETURNING generation INTO invalidation_generation; + + IF invalidation_generation IS NULL THEN + RETURN NULL; + END IF; + + INSERT INTO authorization_invalidation_receipts + (community_id, event_id, generation, request_fingerprint) + VALUES ( + domain_id, + invalidation_event, + invalidation_generation, + digest(invalidation_event::text, 'sha256') + ); + + INSERT INTO authorization_invalidation_floors + (community_id, selector_kind, selector_fingerprint, generation, + sticky_deny, binding_version_floor) + VALUES ( + domain_id, + 'domain', + decode('a3c641c058d6498e4cc4177eb5f9cf6ba32e01c05a21f69aa85661a8044a5c78', 'hex'), + invalidation_generation, + FALSE, + NULL + ) + ON CONFLICT (community_id, selector_kind, selector_fingerprint) DO UPDATE + SET generation = EXCLUDED.generation, + updated_at = clock_timestamp(); + END IF; + RETURN NULL; +END +$$; + +CREATE TRIGGER identity_bindings_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_bindings + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_principals_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_principals + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_revoked_keys_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_revoked_keys + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER identity_retired_pairs_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON identity_retired_pairs + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER relay_members_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON relay_members + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER channel_members_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON channel_members + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER community_bans_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON community_bans + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER channels_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON channels + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER users_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER authorization_invalidation_domains_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER git_repo_publications_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON git_repo_publications + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER media_publications_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON media_publications + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER protected_object_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); + +CREATE TRIGGER audio_session_admissions_authority_epoch + AFTER INSERT OR UPDATE OR DELETE ON audio_session_admissions + FOR EACH ROW EXECUTE FUNCTION advance_authorization_authority_epoch(); diff --git a/migrations/0038_client_status_fanout_withdrawals.sql b/migrations/0038_client_status_fanout_withdrawals.sql new file mode 100644 index 0000000000..eb0371cca7 --- /dev/null +++ b/migrations/0038_client_status_fanout_withdrawals.sql @@ -0,0 +1,21 @@ +-- Retain the exact current revision superseded by a withdrawal so every +-- authenticated connection that displayed that author can receive a +-- strictly newer opaque withdrawal. This remains server-side reconciliation +-- state and is never serialized into the client projection. + +ALTER TABLE client_status_revisions + ADD COLUMN supersedes_revision BIGINT; + +UPDATE client_status_revisions +SET supersedes_revision = revision - 1 +WHERE disposition = 'withdrawn'; + +ALTER TABLE client_status_revisions + ADD CONSTRAINT client_status_revisions_withdrawal CHECK ( + (disposition = 'current' AND supersedes_revision IS NULL) + OR + (disposition = 'withdrawn' + AND supersedes_revision IS NOT NULL + AND supersedes_revision > 0 + AND revision > supersedes_revision) + ); diff --git a/migrations/0039_protected_community_lifecycle_guard.sql b/migrations/0039_protected_community_lifecycle_guard.sql new file mode 100644 index 0000000000..810e372e94 --- /dev/null +++ b/migrations/0039_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/0040_protected_domain_marker_guard.sql b/migrations/0040_protected_domain_marker_guard.sql new file mode 100644 index 0000000000..76d9803b6b --- /dev/null +++ b/migrations/0040_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/0041_audio_cleanup_requests.sql b/migrations/0041_audio_cleanup_requests.sql new file mode 100644 index 0000000000..f0e4cf24fa --- /dev/null +++ b/migrations/0041_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/0042_git_policy_authority_epoch.sql b/migrations/0042_git_policy_authority_epoch.sql new file mode 100644 index 0000000000..dcd9c8b3cc --- /dev/null +++ b/migrations/0042_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/0043_audio_admission_visibility.sql b/migrations/0043_audio_admission_visibility.sql new file mode 100644 index 0000000000..7eaaba11bc --- /dev/null +++ b/migrations/0043_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(); diff --git a/migrations/0044_identity_public_projection_retirement.sql b/migrations/0044_identity_public_projection_retirement.sql new file mode 100644 index 0000000000..e6182bd202 --- /dev/null +++ b/migrations/0044_identity_public_projection_retirement.sql @@ -0,0 +1,80 @@ +-- Durable, provider-neutral reconciliation for the optional public identity +-- projection. O3 lifecycle rows remain the authority; these tables contain +-- only public event coordinates and opaque binding generations. + +CREATE TABLE identity_public_projection_heads ( + community_id UUID NOT NULL REFERENCES communities(id), + relay_pubkey BYTEA NOT NULL, + subject_pubkey BYTEA NOT NULL, + event_id BYTEA NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + disposition TEXT NOT NULL, + source_binding_id UUID, + source_binding_version BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, relay_pubkey, subject_pubkey), + FOREIGN KEY (community_id, source_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(relay_pubkey) = 32), + CHECK (length(subject_pubkey) = 32), + CHECK (length(event_id) = 32), + CHECK (disposition IN ('active', 'inactive')), + CHECK (source_binding_version IS NULL OR source_binding_version > 0), + CHECK ( + (source_binding_id IS NULL AND source_binding_version IS NULL) + OR + (source_binding_id IS NOT NULL AND source_binding_version IS NOT NULL) + ) +); + +CREATE TABLE identity_public_projection_retirements ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + relay_pubkey BYTEA NOT NULL, + old_pubkey BYTEA NOT NULL, + source_binding_id UUID, + source_binding_version BIGINT, + operation_kind TEXT NOT NULL, + phase TEXT NOT NULL DEFAULT 'projection', + outcome TEXT, + event_id BYTEA, + attempts BIGINT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + claim_token UUID, + lease_until TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id, relay_pubkey), + FOREIGN KEY (community_id, operation_id) + REFERENCES identity_lifecycle_operations (community_id, operation_id), + FOREIGN KEY (community_id, source_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(relay_pubkey) = 32), + CHECK (length(old_pubkey) = 32), + CHECK (source_binding_version IS NULL OR source_binding_version > 0), + CHECK ( + (source_binding_id IS NULL AND source_binding_version IS NULL) + OR + (source_binding_id IS NOT NULL AND source_binding_version IS NOT NULL) + ), + CHECK (operation_kind IN ('revoke_key', 'rotate')), + CHECK (phase IN ('projection', 'delivery', 'completed', 'superseded')), + CHECK (outcome IS NULL OR outcome IN ( + 'no_projection', 'already_inactive', 'replaced_inactive', + 'newer_binding', 'newer_projection' + )), + CHECK (event_id IS NULL OR length(event_id) = 32), + CHECK (attempts >= 0), + CHECK ((claim_token IS NULL) = (lease_until IS NULL)), + CHECK ( + (phase IN ('completed', 'superseded') AND completed_at IS NOT NULL) + OR + (phase IN ('projection', 'delivery') AND completed_at IS NULL) + ) +); + +CREATE INDEX idx_identity_public_projection_retirements_ready + ON identity_public_projection_retirements + (phase, next_attempt_at, community_id, operation_id) + WHERE phase IN ('projection', 'delivery'); diff --git a/migrations/0045_authorization_delegated_relationship_selector.sql b/migrations/0045_authorization_delegated_relationship_selector.sql new file mode 100644 index 0000000000..69e4ad85b5 --- /dev/null +++ b/migrations/0045_authorization_delegated_relationship_selector.sql @@ -0,0 +1,19 @@ +-- Add the exact delegated-relationship selector introduced by the O4 +-- trusted-evidence contract repair. Existing selector fingerprints and floors +-- remain byte-for-byte unchanged. + +ALTER TABLE authorization_invalidation_floors + DROP CONSTRAINT authorization_invalidation_floors_selector_kind_check; + +ALTER TABLE authorization_invalidation_floors + ADD CONSTRAINT authorization_invalidation_floors_selector_kind_check + CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner', + 'delegated_relationship' + )); diff --git a/schema/schema.sql b/schema/schema.sql index 9f228b7273..0d8960f3cd 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -551,6 +551,125 @@ CREATE INDEX idx_identity_lifecycle_operations_principal CREATE INDEX idx_identity_lifecycle_operations_key ON identity_lifecycle_operations (community_id, pubkey, created_at); +-- ── Authorization invalidation authority ────────────────────────────────────── +-- Generations and selector floors are durable authority. Cross-node pub/sub +-- carries only a hint that consumers should reconcile from these tables. + +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 TABLE authorization_invalidation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + event_id UUID NOT NULL, + generation BIGINT NOT NULL CHECK (generation > 0), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, event_id), + UNIQUE (community_id, generation) +); + +CREATE TABLE authorization_invalidation_floors ( + community_id UUID NOT NULL REFERENCES communities(id), + selector_kind TEXT NOT NULL CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner', + 'delegated_relationship' + )), + selector_fingerprint BYTEA NOT NULL CHECK (length(selector_fingerprint) = 32), + generation BIGINT NOT NULL CHECK (generation > 0), + sticky_deny BOOLEAN NOT NULL DEFAULT FALSE, + binding_version_floor BIGINT CHECK (binding_version_floor > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, selector_kind, selector_fingerprint), + FOREIGN KEY (community_id, generation) + REFERENCES authorization_invalidation_receipts (community_id, generation), + CHECK ((selector_kind = 'binding') = (binding_version_floor IS NOT NULL)) +); + +CREATE INDEX idx_authorization_invalidation_floors_generation + ON authorization_invalidation_floors (community_id, generation); + +-- Transaction-owned protected-operation idempotency. This is commit protocol +-- state, not an authorization decision or operator audit log. +CREATE TABLE authorization_operation_receipts ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + operation_kind TEXT NOT NULL CHECK ( + length(operation_kind) > 0 AND length(operation_kind) <= 128 + ), + request_fingerprint BYTEA NOT NULL CHECK (length(request_fingerprint) = 32), + result_version SMALLINT NOT NULL DEFAULT 1 CHECK (result_version > 0), + result_payload BYTEA NOT NULL CHECK (octet_length(result_payload) <= 65536), + lease_expires_at TIMESTAMPTZ NOT NULL, + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, operation_id) +); + +CREATE INDEX idx_authorization_operation_receipts_committed_at + ON authorization_operation_receipts (community_id, committed_at); + +CREATE FUNCTION authorization_operation_expiry_guard() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.lease_expires_at <= clock_timestamp() THEN + RAISE EXCEPTION 'protected operation authorization expired before commit' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER authorization_operation_expiry + AFTER INSERT OR UPDATE OF lease_expires_at + ON authorization_operation_receipts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW + EXECUTE FUNCTION authorization_operation_expiry_guard(); + +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); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the