From b70b41c3bed402450f268af3db2fdbdf2733bf8f Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Wed, 28 Oct 2026 17:06:26 -0400 Subject: [PATCH 01/11] BUZZ-413: Fence new message writes Signed-off-by: Ravneet Arora --- crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/runtime/admitted_tx.rs | 19 + crates/buzz-db/src/store/event.rs | 2 + crates/buzz-db/src/store/mod.rs | 4 + crates/buzz-db/src/store/user.rs | 2 + .../buzz-db/src/store/user_deletion_fences.rs | 18 +- .../src/store/user_deletion_write_guard.rs | 108 +++++ ...ser_deletion_write_guard_postgres_tests.rs | 434 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 6 + crates/buzz-relay/src/handlers/mod.rs | 2 + .../user_deletion_message_postgres_tests.rs | 235 ++++++++++ docs/user-deletion-message-fencing.md | 44 ++ 12 files changed, 870 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-db/src/store/user_deletion_write_guard.rs create mode 100644 crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs create mode 100644 crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs create mode 100644 docs/user-deletion-message-fencing.md diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index d0a2ffcb224..3a6009e6309 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -37,6 +37,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// A permanent user-deletion restriction refuses this serving write. + #[error("identity is restricted for user deletion")] + UserDeletionRestricted, + /// JSON serialization or deserialization failed. #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index 2e4ce60a758..b3598f6184b 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -145,6 +145,25 @@ impl AdmittedTx { &mut self.tx } + pub(crate) async fn guard_message_author(&mut self, event: &nostr::Event) -> Result<()> { + crate::store::user_deletion_write_guard::guard_message(&mut self.tx, self.community, event) + .await + } + + pub(crate) async fn guard_agent_owner_assignment( + &mut self, + agent: &[u8], + owner: &[u8], + ) -> Result<()> { + crate::store::user_deletion_write_guard::guard_owner_assignment( + &mut self.tx, + self.community, + agent, + owner, + ) + .await + } + /// Record that an event committed with this transaction belongs to /// `channel_id`, so [`AdmittedTx::commit`] refreshes the channel's TTL. pub(crate) fn record_channel_event(&mut self, channel_id: Option, kind: i32) { diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index c279e4831d0..4e30b11dbee 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -477,6 +477,7 @@ async fn insert_event_on( return Err(DbError::EphemeralEventRejected(kind_u16)); } + tx.guard_message_author(event).await?; let id_bytes = event.id.as_bytes(); let pubkey_bytes = event.pubkey.to_bytes(); let sig_bytes = event.sig.serialize(); @@ -1865,6 +1866,7 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( return Err(DbError::EphemeralEventRejected(kind_u16)); } + tx.guard_message_author(event).await?; let id_bytes = event.id.as_bytes(); let pubkey_bytes = event.pubkey.to_bytes(); let sig_bytes = event.sig.serialize(); diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index d36fa4d0894..39e3ddfd82c 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -30,6 +30,10 @@ pub mod nip_fi_binding; pub mod user_deletion; /// Operator-global user restrictions and identity-lock primitives. pub mod user_deletion_fences; +pub(crate) mod user_deletion_write_guard; + +#[cfg(test)] +mod user_deletion_write_guard_postgres_tests; #[cfg(test)] mod event_follow_up_postgres_tests; diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 2c161e8a026..77ac1de6a13 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -354,6 +354,8 @@ async fn set_agent_owner_with_operation( operation: crate::observability::WriterOperation, ) -> Result { let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; + tx.guard_agent_owner_assignment(agent_pubkey, owner_pubkey) + .await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( diff --git a/crates/buzz-db/src/store/user_deletion_fences.rs b/crates/buzz-db/src/store/user_deletion_fences.rs index 2d437fff094..3be9955f759 100644 --- a/crates/buzz-db/src/store/user_deletion_fences.rs +++ b/crates/buzz-db/src/store/user_deletion_fences.rs @@ -1,5 +1,6 @@ -//! Operator-global restriction storage and identity locks; no serving or worker -//! path calls these helpers yet. Installing fences does not itself enforce them. +//! Operator-global restriction storage and identity locks. New stream/forum +//! messages and agent-owner assignment consult these through the write guard. +//! Other serving paths and the real fencing worker are not integrated yet. //! //! Callers use a writer-pool READ COMMITTED transaction. Acquire existing //! community guards first, then `lock_request` for the worker, then all human @@ -150,19 +151,20 @@ async fn lock( /// For multiple identities, acquire the full shared set first. The caller must /// refuse a restricted operation and retain this transaction through any write. /// An agent lookup is scoped; it does not resolve or check the human owner. -/// The planned agent-write path locks the verified owner and agent together, -/// then reads the agent restriction only. This requires the gate to install the +/// The message-write path locks the verified owner and agent together, then +/// reads the signer's restrictions, not the owner's. This requires the gate to install the /// human and all owned-agent restrictions atomically, and creation/assignment -/// paths to reject restricted owners. These callers are not wired in yet. +/// paths to reject restricted owners. The real gate is not wired in yet. pub async fn find( tx: &mut Transaction<'_, Postgres>, identity: FenceIdentity, ) -> Result> { lock_shared(tx, &[identity]).await?; - read(tx, identity).await + read_locked(tx, identity).await } -async fn read( +/// Read one exact restriction after the caller has locked its identity. +pub(super) async fn read_locked( tx: &mut Transaction<'_, Postgres>, identity: FenceIdentity, ) -> Result> { @@ -226,7 +228,7 @@ pub async fn insert( ) .bind(identity.pubkey.to_hex()).bind(identity.scope_id()).bind(request_id).bind(&owner) .execute(&mut **tx).await?; - let saved = read(tx, identity) + let saved = read_locked(tx, identity) .await? .ok_or_else(|| DbError::DeletionSafety("fence insertion returned no row".into()))?; if saved.request_id != request_id || saved.source_owner_pubkey != owner { diff --git a/crates/buzz-db/src/store/user_deletion_write_guard.rs b/crates/buzz-db/src/store/user_deletion_write_guard.rs new file mode 100644 index 00000000000..b16b415c93b --- /dev/null +++ b/crates/buzz-db/src/store/user_deletion_write_guard.rs @@ -0,0 +1,108 @@ +//! First serving integration: new stream/forum messages and agent ownership. +//! Other writes are not fenced here; real deletion must remain disabled until +//! every serving path and external-work drain has been integrated. + +use buzz_core::{kind, CommunityId}; +use nostr::{Event, PublicKey}; +use sqlx::{Postgres, Transaction}; + +use super::user_deletion_fences::{lock_exclusive, lock_shared, read_locked, FenceIdentity}; +use crate::{DbError, Result}; + +pub(crate) async fn guard_message( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event: &Event, +) -> Result<()> { + if !matches!( + u32::from(event.kind.as_u16()), + kind::KIND_STREAM_MESSAGE + | kind::KIND_STREAM_MESSAGE_V2 + | kind::KIND_FORUM_POST + | kind::KIND_FORUM_COMMENT + ) { + return Ok(()); + } + + let signer = event.pubkey; + let owner = owner(tx, community, signer).await?; + let mut identities = vec![ + FenceIdentity::user(signer), + FenceIdentity::agent(community, signer), + ]; + if let Some(owner) = owner { + identities.push(FenceIdentity::user(owner)); + } + // Global signer lock also coordinates the first assignment of an owner. + // Never derive the owner from a cached session or an event tag. + lock_shared(tx, &identities).await?; + if owner != self::owner(tx, community, signer).await? { + // Restart in a new transaction: adding another owner lock now could + // invert the global-before-scoped ordering. No write has occurred. + return Err(DbError::AccessDenied( + "agent ownership changed; retry the write".into(), + )); + } + check_signer(tx, community, signer).await +} + +pub(crate) async fn guard_owner_assignment( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + agent: &[u8], + owner: &[u8], +) -> Result<()> { + let agent = parse_key(agent)?; + let owner = parse_key(owner)?; + lock_shared( + tx, + &[FenceIdentity::user(agent), FenceIdentity::user(owner)], + ) + .await?; + // Ownership must not change while a message holds the scoped shared lock. + lock_exclusive(tx, &[FenceIdentity::agent(community, agent)]).await?; + if read_locked(tx, FenceIdentity::user(owner)).await?.is_some() { + return Err(DbError::UserDeletionRestricted); + } + check_signer(tx, community, agent).await +} + +async fn owner( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + signer: PublicKey, +) -> Result> { + let owner: Option>> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(signer.to_bytes().as_slice()) + .fetch_optional(&mut **tx) + .await?; + owner.flatten().map(|key| parse_key(&key)).transpose() +} + +fn parse_key(key: &[u8]) -> Result { + PublicKey::from_slice(key) + .map_err(|_| DbError::InvalidData("invalid user-deletion identity key".into())) +} + +async fn check_signer( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + signer: PublicKey, +) -> Result<()> { + // Read the signer's restrictions, not the owner's. Check both possible + // scopes even if the profile/ownership row is absent: those mutable rows + // must not determine whether a permanent restriction still applies. + if read_locked(tx, FenceIdentity::user(signer)) + .await? + .is_some() + || read_locked(tx, FenceIdentity::agent(community, signer)) + .await? + .is_some() + { + return Err(DbError::UserDeletionRestricted); + } + Ok(()) +} diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs new file mode 100644 index 00000000000..10f0459a7f0 --- /dev/null +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -0,0 +1,434 @@ +use super::user_deletion_fences::{self as fences, FenceIdentity}; +use crate::{event, Db, DbError}; +use buzz_core::CommunityId; +use nostr::{Event, EventBuilder, Keys, Kind}; +use sqlx::{PgPool, Postgres, Transaction}; +use std::time::Duration; +use uuid::Uuid; + +struct Fixture { + pool: PgPool, + db: Db, + community: CommunityId, + lease: crate::entity_deletion::EntityLease, +} + +impl Fixture { + async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("message-fences-{}.test", Uuid::new_v4())) + .await + .unwrap() + .id; + let request = db + .user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", Duration::from_secs(300)) + .await + .unwrap() + .unwrap(); + Self { + pool, + db, + community, + lease, + } + } + + async fn fence(&self, keys: &Keys, owner: Option<&Keys>) { + let mut tx = self.pool.begin().await.unwrap(); + self.fence_in(&mut tx, keys, owner).await; + tx.commit().await.unwrap(); + } + + async fn fence_in( + &self, + tx: &mut Transaction<'_, Postgres>, + keys: &Keys, + owner: Option<&Keys>, + ) { + let identity = if owner.is_some() { + FenceIdentity::agent(self.community, keys.public_key()) + } else { + FenceIdentity::user(keys.public_key()) + }; + fences::insert(tx, identity, &self.lease, owner.map(Keys::public_key)) + .await + .unwrap(); + } + + async fn agent(&self, owner: &Keys) -> Keys { + self.db + .ensure_user(self.community, owner.public_key().as_bytes()) + .await + .unwrap(); + let agent = Keys::generate(); + self.db + .ensure_user(self.community, agent.public_key().as_bytes()) + .await + .unwrap(); + self.db + .set_agent_owner( + self.community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + agent + } +} + +fn message(keys: &Keys, kind: u16) -> Event { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .sign_with_keys(keys) + .unwrap() +} + +fn restricted(result: crate::Result<(buzz_core::StoredEvent, bool)>) { + assert!( + matches!(result, Err(DbError::UserDeletionRestricted)), + "{result:?}" + ); +} + +async fn wait_for_waiter(pool: &PgPool, pid: i32) { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_locks WHERE pid = $1 AND locktype = 'advisory' AND NOT granted)").bind(pid).fetch_one(pool).await.unwrap(); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("production writer must wait for the identity lock"); +} + +async fn writer( + f: &Fixture, + event: Event, +) -> ( + i32, + tokio::task::JoinHandle>, +) { + let mut tx = crate::begin_community_event_write_transaction( + &f.pool, + f.community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); + let pid = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(tx.conn()) + .await + .unwrap(); + let task = tokio::spawn(async move { + let result = event::insert_event_in_transaction(&mut tx, &event, None).await; + if result.is_ok() { + tx.commit().await.unwrap(); + } else { + tx.rollback().await.unwrap(); + } + result + }); + (pid, task) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn message_writers_enforce_global_and_scoped_fences_even_without_profiles() { + let f = Fixture::new().await; + let user = Keys::generate(); + let agent = f.agent(&user).await; + let other = Keys::generate(); + let other_community = + f.db.ensure_configured_community(&format!("unrestricted-{}.test", Uuid::new_v4())) + .await + .unwrap() + .id; + for keys in [&user, &agent, &other] { + f.db.insert_event_with_thread_metadata(f.community, &message(keys, 9), None, None) + .await + .unwrap(); + } + f.fence(&user, None).await; + f.fence(&agent, Some(&user)).await; + for kind in [9, 40002, 45001, 45003] { + for keys in [&user, &agent] { + restricted( + f.db.insert_event(f.community, &message(keys, kind), None) + .await, + ); + restricted( + f.db.insert_event_with_thread_metadata( + f.community, + &message(keys, kind), + None, + None, + ) + .await, + ); + } + f.db.insert_event(f.community, &message(&other, kind), None) + .await + .unwrap(); + f.db.insert_event(other_community, &message(&agent, kind), None) + .await + .unwrap(); + restricted( + f.db.insert_event(other_community, &message(&user, kind), None) + .await, + ); + } + sqlx::query("DELETE FROM users WHERE community_id = $1 AND pubkey = $2") + .bind(f.community.as_uuid()) + .bind(agent.public_key().to_bytes().as_slice()) + .execute(&f.pool) + .await + .unwrap(); + restricted( + f.db.insert_event(f.community, &message(&agent, 9), None) + .await, + ); + f.db.ensure_user(f.community, agent.public_key().as_bytes()) + .await + .unwrap(); + restricted( + f.db.insert_event_with_thread_metadata(f.community, &message(&agent, 9), None, None) + .await, + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn already_started_message_commits_before_fence_then_new_message_is_rejected() { + let f = Fixture::new().await; + let user = Keys::generate(); + let event = message(&user, 9); + let mut tx = crate::begin_community_event_write_transaction( + &f.pool, + f.community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); + event::insert_event_in_transaction(&mut tx, &event, None) + .await + .unwrap(); + let mut gate = f.pool.begin().await.unwrap(); + let pid = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *gate) + .await + .unwrap(); + let lease = f.lease.clone(); + let identity = FenceIdentity::user(user.public_key()); + let gating = tokio::spawn(async move { + fences::insert(&mut gate, identity, &lease, None) + .await + .unwrap(); + gate.commit().await.unwrap(); + }); + wait_for_waiter(&f.pool, pid).await; + assert!(f + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .unwrap() + .is_none()); + tx.commit().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), gating) + .await + .unwrap() + .unwrap(); + assert!(f + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .unwrap() + .is_some()); + restricted( + f.db.insert_event(f.community, &message(&user, 9), None) + .await, + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn message_waits_for_human_or_agent_owner_fence_and_reads_after_commit() { + let f = Fixture::new().await; + for is_agent in [false, true] { + let user = Keys::generate(); + let sender = if is_agent { + f.agent(&user).await + } else { + user.clone() + }; + let event = message(&sender, 9); + let mut gate = f.pool.begin().await.unwrap(); + fences::lock_request(&mut gate, &f.lease).await.unwrap(); + fences::lock_exclusive(&mut gate, &[FenceIdentity::user(user.public_key())]) + .await + .unwrap(); + let (pid, sending) = writer(&f, event.clone()).await; + wait_for_waiter(&f.pool, pid).await; + f.fence_in(&mut gate, &user, None).await; + if is_agent { + f.fence_in(&mut gate, &sender, Some(&user)).await; + } + gate.commit().await.unwrap(); + restricted( + tokio::time::timeout(Duration::from_secs(5), sending) + .await + .unwrap() + .unwrap(), + ); + assert!(f + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .unwrap() + .is_none()); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ownership_change_while_waiting_rejects_and_retry_uses_current_owner() { + let f = Fixture::new().await; + let agent = Keys::generate(); + let owner = Keys::generate(); + f.db.ensure_user(f.community, owner.public_key().as_bytes()) + .await + .unwrap(); + f.db.ensure_user(f.community, agent.public_key().as_bytes()) + .await + .unwrap(); + let mut assignment = f.pool.begin().await.unwrap(); + fences::lock_shared( + &mut assignment, + &[ + FenceIdentity::user(agent.public_key()), + FenceIdentity::user(owner.public_key()), + ], + ) + .await + .unwrap(); + fences::lock_exclusive( + &mut assignment, + &[FenceIdentity::agent(f.community, agent.public_key())], + ) + .await + .unwrap(); + let event = message(&agent, 9); + let (pid, sending) = writer(&f, event.clone()).await; + wait_for_waiter(&f.pool, pid).await; + sqlx::query("UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3") + .bind(owner.public_key().to_bytes().as_slice()) + .bind(f.community.as_uuid()) + .bind(agent.public_key().to_bytes().as_slice()) + .execute(&mut *assignment) + .await + .unwrap(); + assignment.commit().await.unwrap(); + assert!( + matches!(sending.await.unwrap(), Err(DbError::AccessDenied(reason)) if reason.contains("ownership changed")) + ); + assert!(f + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .unwrap() + .is_none()); + f.db.insert_event(f.community, &event, None).await.unwrap(); + f.fence(&agent, Some(&owner)).await; + restricted( + f.db.insert_event(f.community, &message(&agent, 9), None) + .await, + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn first_owner_assignment_waits_for_messages_and_rejects_restricted_owners() { + let f = Fixture::new().await; + let agent = Keys::generate(); + let owner = Keys::generate(); + f.db.ensure_user(f.community, owner.public_key().as_bytes()) + .await + .unwrap(); + f.db.ensure_user(f.community, agent.public_key().as_bytes()) + .await + .unwrap(); + let mut tx = crate::begin_community_event_write_transaction( + &f.pool, + f.community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); + event::insert_event_in_transaction(&mut tx, &message(&agent, 9), None) + .await + .unwrap(); + // A dedicated single-connection pool exposes the actual setter's backend. + let assign_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&crate::test_support::database_url()) + .await + .unwrap(); + let pid = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&assign_pool) + .await + .unwrap(); + let assignment = tokio::spawn({ + let (agent, owner, community) = (agent.public_key(), owner.public_key(), f.community); + async move { + super::user::set_agent_owner( + &assign_pool, + community, + agent.as_bytes(), + owner.as_bytes(), + ) + .await + } + }); + wait_for_waiter(&f.pool, pid).await; + tx.commit().await.unwrap(); + assert!(assignment.await.unwrap().unwrap()); + f.fence(&owner, None).await; + let another = Keys::generate(); + f.db.ensure_user(f.community, another.public_key().as_bytes()) + .await + .unwrap(); + assert!(matches!( + f.db.set_agent_owner( + f.community, + another.public_key().as_bytes(), + owner.public_key().as_bytes() + ) + .await, + Err(DbError::UserDeletionRestricted) + )); + let saved: Option> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2", + ) + .bind(f.community.as_uuid()) + .bind(another.public_key().to_bytes().as_slice()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert!(saved.is_none()); +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index c2ab9c3ba53..6e477072fac 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3703,6 +3703,12 @@ async fn ingest_event_inner( buzz_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) } + buzz_db::DbError::UserDeletionRestricted => IngestError::Rejected( + "restricted: identity is restricted for user deletion".into(), + ), + buzz_db::DbError::AccessDenied(reason) => { + IngestError::Rejected(format!("restricted: {reason}")) + } other => IngestError::Internal(format!("error: database error: {other}")), }); } diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index cd610668906..a887bd60dba 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -54,6 +54,8 @@ pub mod side_effects; mod system_channel_postgres_tests; /// Per-filter query scope for system channels. pub(crate) mod system_channels; +#[cfg(test)] +mod user_deletion_message_postgres_tests; /// Extract an optional TTL (in seconds) from a Nostr event's `ttl` tag, /// applying the server-side override when configured. diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs new file mode 100644 index 00000000000..e6627646148 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -0,0 +1,235 @@ +//! Exercise both transport entry points and the real message transaction. + +use std::{sync::Arc, time::Duration}; + +use buzz_core::TenantContext; +use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::Value; +use uuid::Uuid; + +use super::group_state_postgres_tests::{community, create_channel, test_conn}; +use crate::state::AppState; + +struct Fixture { + state: Arc, + pool: sqlx::PgPool, + tenant: TenantContext, + channel: Uuid, + user: Keys, + agent: Keys, + peer: Keys, + url: String, + server: tokio::task::JoinHandle<()>, +} + +impl Fixture { + async fn new() -> Self { + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("Postgres and Redis"); + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let tenant = community(&state, "message-deletion").await; + let (user, agent, peer) = (Keys::generate(), Keys::generate(), Keys::generate()); + state + .db + .ensure_user(tenant.community(), user.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .ensure_user(tenant.community(), agent.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .set_agent_owner( + tenant.community(), + agent.public_key().as_bytes(), + user.public_key().as_bytes(), + ) + .await + .unwrap(); + let channel = create_channel( + &state, + &tenant, + &peer, + &[ + &["p", &user.public_key().to_hex()], + &["p", &agent.public_key().to_hex()], + ], + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let router = crate::router::build_router(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + Self { + url, + server, + state, + pool, + tenant, + channel, + user, + agent, + peer, + } + } + + fn message(&self, key: &Keys, kind: u16, root: Option<&Event>) -> Event { + let mut tags = vec![Tag::parse(["h", &self.channel.to_string()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", &root.id.to_hex(), "", "root"]).unwrap()); + tags.push(Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()); + } + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(key) + .unwrap() + } + + async fn fence(&self) { + let request = self + .state + .db + .user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = self.state.db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", Duration::from_secs(300)) + .await + .unwrap() + .unwrap(); + let mut tx = self.pool.begin().await.unwrap(); + let user = FenceIdentity::user(self.user.public_key()); + let agent = FenceIdentity::agent(self.tenant.community(), self.agent.public_key()); + fences::lock_request(&mut tx, &lease).await.unwrap(); + fences::lock_exclusive(&mut tx, &[user, agent]) + .await + .unwrap(); + fences::insert(&mut tx, user, &lease, None).await.unwrap(); + fences::insert(&mut tx, agent, &lease, Some(self.user.public_key())) + .await + .unwrap(); + tx.commit().await.unwrap(); + } + + async fn send(&self, key: &Keys, event: &Event, websocket: bool) -> (bool, String) { + if websocket { + let (conn, mut rx) = test_conn(&self.tenant, key); + if let crate::connection::AuthState::Authenticated(ctx) = + &mut *conn.auth_state.lock().unwrap() + { + ctx.scopes = vec![buzz_auth::Scope::MessagesWrite]; + // Intentionally no cached owner: the write must use PostgreSQL. + ctx.agent_owner_pubkey = None; + } + super::event::handle_event(event.clone(), conn, self.state.clone()).await; + let axum::extract::ws::Message::Text(text) = rx.try_recv().unwrap() else { + panic!("expected OK"); + }; + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "OK", "{frame}"); + assert_eq!(frame[1], event.id.to_hex()); + ( + frame[2].as_bool().unwrap(), + frame[3].as_str().unwrap().to_string(), + ) + } else { + let response = reqwest::Client::new() + .post(format!("{}/events", self.url)) + .header("host", self.tenant.host()) + .header("x-pubkey", key.public_key().to_hex()) + .json(event) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + if status.is_success() { + ( + body["accepted"].as_bool().unwrap(), + body["message"].as_str().unwrap_or("").to_owned(), + ) + } else { + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + (false, body["error"].as_str().unwrap().to_owned()) + } + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn http_and_websocket_messages_honor_user_and_agent_fences() { + let f = Fixture::new().await; + let root = f.message(&f.peer, 9, None); + assert!(f.send(&f.peer, &root, false).await.0); + for websocket in [false, true] { + for key in [&f.user, &f.agent, &f.peer] { + assert!( + f.send(key, &f.message(key, 9, Some(&root)), websocket) + .await + .0 + ); + } + } + let counters: (i64, i64) = sqlx::query_as("SELECT reply_count::bigint, descendant_count::bigint FROM thread_metadata WHERE community_id = $1 AND event_id = $2").bind(f.tenant.community().as_uuid()).bind(root.id.as_bytes().as_slice()).fetch_one(&f.pool).await.unwrap(); + assert_eq!(counters, (6, 6), "fixture must create real replies"); + f.fence().await; + for websocket in [false, true] { + for key in [&f.user, &f.agent] { + for kind in [9, 40002, 45001, 45003] { + let event = f.message(key, kind, Some(&root)); + let (accepted, reason) = f.send(key, &event, websocket).await; + assert!(!accepted, "fenced event accepted"); + assert_eq!( + reason, + "restricted: identity is restricted for user deletion" + ); + assert!(f + .state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_none()); + } + } + } + let after: (i64, i64) = sqlx::query_as("SELECT reply_count::bigint, descendant_count::bigint FROM thread_metadata WHERE community_id = $1 AND event_id = $2").bind(f.tenant.community().as_uuid()).bind(root.id.as_bytes().as_slice()).fetch_one(&f.pool).await.unwrap(); + assert_eq!(after, counters, "rejected replies must not update counters"); + for websocket in [false, true] { + assert!( + f.send(&f.peer, &f.message(&f.peer, 9, Some(&root)), websocket) + .await + .0 + ); + } +} diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md new file mode 100644 index 00000000000..b9c3d2f47e3 --- /dev/null +++ b/docs/user-deletion-message-fencing.md @@ -0,0 +1,44 @@ +# User-deletion fencing: message integration + +This is the first serving integration after fence storage (PR #8632). It does not enable the real user-deletion worker. Do not install production fences until all required serving paths, fencing inventory, and external-work draining are integrated and deployed on every writer. + +## Coverage + +| Operation | This change | +| --- | --- | +| New stream messages and replies, kinds 9 and 40002 | Guarded inside event persistence | +| New forum posts and comments, kinds 45001 and 45003 | Guarded inside event persistence | +| HTTP `/events` and WebSocket `EVENT` for these kinds | Same guard and fixed rejection | +| Agent's first owner assignment through `set_agent_owner` | Coordinates with message locks; refuses restricted owners and identities | +| Other kinds, including edits, reactions, profiles, commands, ephemeral typing, and gift wraps | Follow-up work | +| Uploads, git operations, agent execution, and already-started external work | Follow-up work | + +The guard is called by both event insertion implementations in `buzz-db/src/store/event.rs`. It runs before the event INSERT, thread metadata, counters, mentions, and post-commit publication. It only applies to the four kinds above. Shared locks remain held until the caller's transaction commits or rolls back. There is no restriction cache and no separate transaction holding the message's identity locks. + +## A normal message + +1. The transports authenticate and validate the event through their existing paths. The shared ingest path requires the event signer to equal the authenticated identity for these message kinds. +2. The database writer starts its existing transaction and acquires the shared community deletion lock. +3. Read `users.agent_owner_pubkey` for the signer in this community. Do not trust an owner in a session cache or event tag. +4. Acquire shared identity locks in the existing canonical order: the global signer key, the verified owner's global key when present, and the signer's scoped community key. +5. Read ownership again. If it changed while waiting, reject with a retry message; release the transaction instead of acquiring more locks out of order. +6. Read the signer's global and community-scoped restriction rows. Neither lookup reads the owner's restriction. Refuse with `restricted: identity is restricted for user deletion` if either exists. A missing profile does not remove a restriction. +7. Persist the message and its transactional derived records, then commit and release all locks. Continue the existing post-commit publication. + +A global signer lock and lookup are retained even for agents because role/profile rows are mutable. The scoped signer lookup is retained even without an ownership row because permanent restrictions must survive profile deletion. This is two exact indexed lookups, not a new batch-read API. The guard reuses the internal locked-read helper after acquiring the full lock set once. + +## Ownership assignment + +`set_agent_owner_with_operation` remains the first-owner-wins conditional update. Before that update, its existing transaction acquires shared locks for the prospective owner and global agent key, then the agent's scoped exclusive lock. It checks the prospective owner's global restriction and the agent's own restrictions. The exclusive agent lock prevents assignment from changing ownership while a message uses the old relationship. This covers both existing public setter entry points, including authorization callers; it is not a claim that every agent lifecycle operation is now fenced. + +## Fencing and in-flight work + +The future fencing worker must lock the request, take the user's exclusive identity lock, discover owned agents under that lock, acquire their exclusive scoped locks, and commit all restrictions and the checkpoint together. If a message owns a shared lock first, the worker waits until that message transaction ends. If the worker owns its exclusive lock first, the message waits and then sees the committed restriction. + +This transaction only protects message persistence. It does not drain uploads or promise that all post-commit delivery has finished when a fence commits. Those are separate later stages. + +## Validation + +Database regressions cover both insertion APIs, all four message kinds, human restrictions across communities, agent restrictions within one community, removal/recreation of profiles, both fencing race directions, owner changes while a sender waits, and assignment blocked by an active message or restricted owner. They observe PostgreSQL lock waiters before releasing a lock rather than depending on a sleep to manufacture the race. + +The relay regression starts a local listening relay, sends HTTP requests over TCP, and exercises the WebSocket EVENT handler, checks the fixed rejection, verifies no rejected event exists, and confirms rejected replies leave thread counters unchanged. The peer remains able to send after the requested user and agent are fenced. From 19765831ef259ce8eb75cb95c7b643ac4acb03a5 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Thu, 29 Oct 2026 16:07:05 -0400 Subject: [PATCH 02/11] Fence remaining user activity and external writes Signed-off-by: Ravneet Arora --- crates/buzz-db/src/runtime/admitted_tx.rs | 13 +- crates/buzz-db/src/store/deletion.rs | 76 +++- crates/buzz-db/src/store/event.rs | 4 +- .../buzz-db/src/store/personal_read/writes.rs | 1 + crates/buzz-db/src/store/replaceable.rs | 2 + ...ser_deletion_actor_lease_postgres_tests.rs | 127 ++++++ .../buzz-db/src/store/user_deletion_fences.rs | 23 +- ...user_deletion_lock_order_postgres_tests.rs | 127 ++++++ .../src/store/user_deletion_write_guard.rs | 23 +- ...ser_deletion_write_guard_postgres_tests.rs | 10 +- crates/buzz-deletion/Cargo.toml | 2 +- crates/buzz-deletion/src/lib.rs | 47 ++- crates/buzz-media/src/error.rs | 14 +- crates/buzz-relay/src/api/bridge.rs | 32 +- crates/buzz-relay/src/api/buzz_v1/handlers.rs | 1 + crates/buzz-relay/src/api/gifs.rs | 13 +- crates/buzz-relay/src/api/git/settings.rs | 33 +- crates/buzz-relay/src/api/git/transport.rs | 124 +++++- crates/buzz-relay/src/api/invites.rs | 40 +- crates/buzz-relay/src/api/media.rs | 40 +- crates/buzz-relay/src/audio/handler.rs | 139 ++++++- crates/buzz-relay/src/handlers/artifact.rs | 2 +- .../src/handlers/command_executor.rs | 5 +- crates/buzz-relay/src/handlers/event.rs | 79 +++- .../buzz-relay/src/handlers/identity_write.rs | 104 +++++ crates/buzz-relay/src/handlers/ingest.rs | 57 ++- crates/buzz-relay/src/handlers/mod.rs | 1 + .../user_deletion_expanded_postgres_tests.rs | 392 ++++++++++++++++++ .../user_deletion_lifecycle_postgres_tests.rs | 116 ++++++ .../user_deletion_message_postgres_tests.rs | 24 +- .../user_deletion_remaining_postgres_tests.rs | 225 ++++++++++ crates/buzz-workflow/src/executor.rs | 32 +- docs/user-deletion-message-fencing.md | 77 ++-- migrations/0075_serving_write_actor.sql | 10 + .../public/community_serving_write_leases.sql | 6 + 35 files changed, 1888 insertions(+), 133 deletions(-) create mode 100644 crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs create mode 100644 crates/buzz-relay/src/handlers/identity_write.rs create mode 100644 crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs create mode 100644 crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs create mode 100644 crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs create mode 100644 migrations/0075_serving_write_actor.sql diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index b3598f6184b..315af96d19f 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -145,11 +145,20 @@ impl AdmittedTx { &mut self.tx } - pub(crate) async fn guard_message_author(&mut self, event: &nostr::Event) -> Result<()> { - crate::store::user_deletion_write_guard::guard_message(&mut self.tx, self.community, event) + pub(crate) async fn guard_identity(&mut self, actor: nostr::PublicKey) -> Result<()> { + crate::store::user_deletion_write_guard::guard_identity(&mut self.tx, self.community, actor) .await } + pub(crate) async fn guard_event_author(&mut self, event: &nostr::Event) -> Result<()> { + crate::store::user_deletion_write_guard::guard_event_author( + &mut self.tx, + self.community, + event, + ) + .await + } + pub(crate) async fn guard_agent_owner_assignment( &mut self, agent: &[u8], diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index d1aaf28a1ff..c7866312cf7 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -718,6 +718,8 @@ pub struct ServingWriteLease { pub community_id: CommunityId, /// Operation category for diagnostics. pub operation: String, + /// Authenticated actor for user-deletion exclusion, if this is a user write. + pub actor_pubkey: Option, /// Process/executor identity. pub owner: String, /// Monotonic lease generation. @@ -2990,6 +2992,9 @@ impl DeletionStore { lease: &ServingWriteLease, ) -> Result<()> { lock_community_deletion_shared(tx, lease.community_id).await?; + if let Some(actor) = lease.actor_pubkey { + super::user_deletion_write_guard::guard_identity(tx, lease.community_id, actor).await?; + } let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -3071,6 +3076,44 @@ impl DeletionStore { operation: &str, owner: &str, lease_duration: Duration, + ) -> Result { + self.acquire_serving_write_lease_for_actor( + community, + operation, + owner, + lease_duration, + None, + ) + .await + } + + /// Acquire an external-effect lease under the actor's shared identity locks. + /// A user fence cannot commit while this exact actor's lease remains live. + pub async fn acquire_actor_serving_write_lease( + &self, + community: CommunityId, + operation: &str, + owner: &str, + lease_duration: Duration, + actor: nostr::PublicKey, + ) -> Result { + self.acquire_serving_write_lease_for_actor( + community, + operation, + owner, + lease_duration, + Some(actor), + ) + .await + } + + async fn acquire_serving_write_lease_for_actor( + &self, + community: CommunityId, + operation: &str, + owner: &str, + lease_duration: Duration, + actor: Option, ) -> Result { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); self.bounded_serving_lease_sql(SERVING_WRITE_LEASE_SQL_TIMEOUT, async |connection| { @@ -3079,11 +3122,14 @@ impl DeletionStore { // The lease INSERT remains in this transaction and rechecks active // state after admission, so a fence cannot interleave. guard_community_write(&mut tx, community).await?; + if let Some(actor) = actor { + super::user_deletion_write_guard::guard_identity(&mut tx, community, actor).await?; + } let row = sqlx::query( "INSERT INTO community_serving_write_leases \ - (community_id, operation, owner, fence_generation, lease_until) \ + (community_id, operation, owner, fence_generation, lease_until, actor_pubkey) \ SELECT id, $2, $3, deletion_fence_generation, \ - now() + make_interval(secs => $4) \ + now() + make_interval(secs => $4), $5 \ FROM communities WHERE id = $1 AND deletion_state = 'active' \ AND deleted_at IS NULL \ RETURNING id, generation, fence_generation, lease_until", @@ -3092,6 +3138,7 @@ impl DeletionStore { .bind(operation) .bind(owner) .bind(lease_seconds) + .bind(actor.map(|key| key.to_hex())) .fetch_optional(&mut *tx) .await? .ok_or_else(|| { @@ -3101,6 +3148,7 @@ impl DeletionStore { id: row.try_get("id")?, community_id: community, operation: operation.to_owned(), + actor_pubkey: actor, owner: owner.to_owned(), generation: row.try_get("generation")?, fence_generation: row.try_get("fence_generation")?, @@ -3133,6 +3181,10 @@ impl DeletionStore { .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; + if let Some(actor) = lease.actor_pubkey { + super::user_deletion_write_guard::guard_identity(&mut tx, lease.community_id, actor) + .await?; + } let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ SET lease_until = now() + make_interval(secs => $6), heartbeat_at = now() \ @@ -3194,6 +3246,14 @@ impl DeletionStore { self.bounded_serving_lease_sql(SERVING_WRITE_LEASE_SQL_TIMEOUT, async |connection| { let mut tx = serving_lease_transaction(connection).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; + if let Some(actor) = lease.actor_pubkey { + super::user_deletion_write_guard::guard_identity( + &mut tx, + lease.community_id, + actor, + ) + .await?; + } let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -4543,6 +4603,17 @@ mod postgres_tests { crate::migration::run_migrations_through(&db.pool, 66) .await .expect("migrate with fence objects present"); + // Keep the historical universal fences for this retirement test, but + // include the additive serving-lease shape required by current writers. + let mut schema_tx = db.pool.begin().await.unwrap(); + sqlx::raw_sql(include_str!( + "../../../../migrations/0075_serving_write_actor.sql" + )) + .execute(&mut *schema_tx) + .await + .unwrap(); + schema_tx.commit().await.unwrap(); + let store = db.deletion_store(); let community = db .ensure_configured_community("optional-fences.example") @@ -7434,6 +7505,7 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); id: Uuid::new_v4(), community_id: community, operation: "isolation-probe".to_owned(), + actor_pubkey: None, owner: "isolation-probe".to_owned(), generation: 0, fence_generation: 0, diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 4e30b11dbee..606edbfcfcf 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -477,7 +477,7 @@ async fn insert_event_on( return Err(DbError::EphemeralEventRejected(kind_u16)); } - tx.guard_message_author(event).await?; + tx.guard_event_author(event).await?; let id_bytes = event.id.as_bytes(); let pubkey_bytes = event.pubkey.to_bytes(); let sig_bytes = event.sig.serialize(); @@ -1866,7 +1866,7 @@ pub(crate) async fn insert_event_with_thread_metadata_tx( return Err(DbError::EphemeralEventRejected(kind_u16)); } - tx.guard_message_author(event).await?; + tx.guard_event_author(event).await?; let id_bytes = event.id.as_bytes(); let pubkey_bytes = event.pubkey.to_bytes(); let sig_bytes = event.sig.serialize(); diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 77df951f595..da6afaea752 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -237,6 +237,7 @@ impl Db { ) .await?; deadlines(tx.conn()).await?; + tx.guard_identity(*actor).await?; let actor = actor.to_bytes(); lock_account(&mut tx, community, &actor).await?; let outcome = apply(&mut tx, community, &actor, intent).await?; diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 1ccee163320..08cb7d76d37 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -109,6 +109,7 @@ async fn replace_parameterized_event_in_transaction_impl( channel_id: Option, precondition: ParameterizedReplacePrecondition<'_>, ) -> Result { + tx.guard_event_author(event).await?; let community_id = tx.community(); let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); @@ -431,6 +432,7 @@ impl Db { ) .await?; tx.set_push_enqueue(self.push_enqueue.clone()); + tx.guard_event_author(event).await?; let transaction_timer = observability::TransactionTimer::start( observability::TransactionOperation::ReplaceAddressableEvent, ); diff --git a/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs new file mode 100644 index 00000000000..93d0f9a3532 --- /dev/null +++ b/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs @@ -0,0 +1,127 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn actor_leases_exclude_fencing_until_release_and_do_not_block_other_users() { + let f = Fixture::new().await; + let user = Keys::generate(); + let other = Keys::generate(); + let store = f.db.deletion_store(); + let mut write = store + .acquire_actor_serving_write_lease( + f.community, + "upload-test", + "writer", + Duration::from_secs(60), + user.public_key(), + ) + .await + .unwrap(); + let mut gate = f.pool.begin().await.unwrap(); + assert!( + matches!(fences::insert(&mut gate, FenceIdentity::user(user.public_key()), &f.lease, None).await, + Err(DbError::AccessDenied(reason)) if reason.contains("still in flight")) + ); + gate.rollback().await.unwrap(); + store + .renew_serving_write_lease(&mut write, Duration::from_secs(60)) + .await + .unwrap(); + store.verify_serving_write_lease(&write).await.unwrap(); + assert!(store.release_serving_write_lease(&write).await.unwrap()); + f.fence(&user, None).await; + assert!(matches!( + store + .acquire_actor_serving_write_lease( + f.community, + "upload-test", + "writer", + Duration::from_secs(60), + user.public_key(), + ) + .await, + Err(DbError::UserDeletionRestricted) + )); + let control = store + .acquire_actor_serving_write_lease( + f.community, + "upload-test", + "writer", + Duration::from_secs(60), + other.public_key(), + ) + .await + .unwrap(); + store.release_serving_write_lease(&control).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn expired_actor_lease_cannot_renew_or_finalize_after_fencing() { + let f = Fixture::new().await; + let user = Keys::generate(); + let store = f.db.deletion_store(); + let mut write = store + .acquire_actor_serving_write_lease( + f.community, + "git-test", + "writer", + Duration::from_secs(60), + user.public_key(), + ) + .await + .unwrap(); + sqlx::query("UPDATE community_serving_write_leases SET lease_until=clock_timestamp()-interval '1 second' WHERE id=$1") + .bind(write.id).execute(&f.pool).await.unwrap(); + f.fence(&user, None).await; + assert!(store + .renew_serving_write_lease(&mut write, Duration::from_secs(60)) + .await + .is_err()); + assert!(store.verify_serving_write_lease(&write).await.is_err()); + let mut tx = f.pool.begin().await.unwrap(); + assert!(store + .guard_transaction_with_serving_lease(&mut tx, &write) + .await + .is_err()); + tx.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn scoped_agent_lease_and_fence_leave_another_community_writable() { + let f = Fixture::new().await; + let owner = Keys::generate(); + let agent = f.agent(&owner).await; + let other = + f.db.ensure_configured_community(&format!("actor-lease-{}.test", Uuid::new_v4())) + .await + .unwrap() + .id; + let store = f.db.deletion_store(); + let write = store + .acquire_actor_serving_write_lease( + other, + "upload-test", + "writer", + Duration::from_secs(60), + agent.public_key(), + ) + .await + .unwrap(); + f.fence(&agent, Some(&owner)).await; + store.verify_serving_write_lease(&write).await.unwrap(); + assert!(matches!( + store + .acquire_actor_serving_write_lease( + f.community, + "upload-test", + "writer", + Duration::from_secs(60), + agent.public_key(), + ) + .await, + Err(DbError::UserDeletionRestricted) + )); + store.release_serving_write_lease(&write).await.unwrap(); +} diff --git a/crates/buzz-db/src/store/user_deletion_fences.rs b/crates/buzz-db/src/store/user_deletion_fences.rs index 3be9955f759..c4cf8a8807f 100644 --- a/crates/buzz-db/src/store/user_deletion_fences.rs +++ b/crates/buzz-db/src/store/user_deletion_fences.rs @@ -1,6 +1,6 @@ -//! Operator-global restriction storage and identity locks. New stream/forum -//! messages and agent-owner assignment consult these through the write guard. -//! Other serving paths and the real fencing worker are not integrated yet. +//! Operator-global restriction storage and identity locks. Shared persistent-event +//! insertion paths and agent-owner assignment consult these through the write guard. +//! Actor leases also protect external effects; the real fencing worker is not integrated yet. //! //! Callers use a writer-pool READ COMMITTED transaction. Acquire existing //! community guards first, then `lock_request` for the worker, then all human @@ -221,6 +221,23 @@ pub async fn insert( lock_request(tx, lease).await?; let request_id = lease.request_id; lock_exclusive(tx, &[identity]).await?; + // Actor leases are registered under these same shared identity locks. + // Refuse completion while admitted external or multi-transaction work runs; + // the executor retries after it finishes, rather than holding locks to poll. + let active: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases + WHERE actor_pubkey = $1 AND ($2::uuid IS NULL OR community_id = $2) + AND lease_until >= clock_timestamp())", + ) + .bind(identity.pubkey.to_hex()) + .bind(identity.scope_id()) + .fetch_one(&mut **tx) + .await?; + if active { + return Err(DbError::AccessDenied( + "identity writes are still in flight; retry fencing".into(), + )); + } let owner = source_owner.map(|key| key.to_hex()); sqlx::query( "INSERT INTO user_deletion_fences (pubkey, scope_community_id, request_id, source_owner_pubkey) diff --git a/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs new file mode 100644 index 00000000000..806c4e1eeb2 --- /dev/null +++ b/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs @@ -0,0 +1,127 @@ +use super::*; + +/// Exercise the actual TTL-before-author settings writer against a message +/// holding the author's shared lock and a queued exclusive fence. PostgreSQL +/// may reorder soft wait edges; neither an error nor a swallowed TTL deadlock +/// is acceptable evidence of safe completion. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn message_then_fence_then_ttl_settings_complete_without_deadlock() { + let f = Fixture::new().await; + let user = Keys::generate(); + let channel = Uuid::new_v4(); + f.db.create_channel_with_id( + f.community, + channel, + "lock-order", + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Open, + None, + user.public_key().as_bytes(), + Some(60), + ) + .await + .unwrap(); + let mut message_tx = crate::begin_community_event_write_transaction( + &f.pool, + f.community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); + let first = message(&user, 9); + event::insert_event_in_transaction(&mut message_tx, &first, Some(channel)) + .await + .unwrap(); + + let mut gate = f.pool.begin().await.unwrap(); + let gate_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *gate) + .await + .unwrap(); + let identity = FenceIdentity::user(user.public_key()); + let lease = f.lease.clone(); + let fence_task = tokio::spawn(async move { + fences::insert(&mut gate, identity, &lease, None).await?; + gate.commit().await?; + crate::Result::Ok(()) + }); + wait_for_waiter(&f.pool, gate_pid).await; + + // A one-connection pool lets us observe the settings writer's actual PID. + let settings_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&crate::test_support::database_url()) + .await + .unwrap(); + let settings_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&settings_pool) + .await + .unwrap(); + let settings_db = Db::from_pool(settings_pool); + let settings_event = message(&user, 9002); + let settings_id = settings_event.id; + let community = f.community; + let settings_task = tokio::spawn(async move { + let mut edit = crate::channel::ChannelMetadataEdit::default(); + edit.columns.ttl_seconds = Some(Some(3600)); + settings_db + .apply_channel_metadata_event(community, channel, &settings_event, edit, &[]) + .await + }); + wait_for_waiter(&f.pool, settings_pid).await; + // The settings transaction really holds the exclusive TTL lock, rather + // than merely waiting on some earlier lock in its path. + let holds_ttl: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE pid=$1 AND locktype='advisory' \ + AND mode='ExclusiveLock' AND granted \ + AND classid=((hashtextextended($2,0) >> 32) & 4294967295)::oid \ + AND objid=(hashtextextended($2,0) & 4294967295)::oid)", + ) + .bind(settings_pid) + .bind(crate::channel::channel_ttl_lock_key(f.community, channel)) + .fetch_one(&f.pool) + .await + .unwrap(); + assert!(holds_ttl); + + tokio::time::timeout(Duration::from_secs(8), async { + message_tx.commit().await.unwrap(); + fence_task.await.unwrap().unwrap(); + let settings = settings_task.await.unwrap(); + let accepted = match settings { + Ok(write) => { + assert!(write.inserted); + true + } + Err(DbError::UserDeletionRestricted) => false, + Err(other) => panic!("settings failed for a non-fence reason: {other:?}"), + }; + assert_eq!( + f.db.get_event_by_id(f.community, settings_id.as_bytes()) + .await + .unwrap() + .is_some(), + accepted + ); + }) + .await + .expect("all three production transactions must finish"); + assert!(f + .db + .get_event_by_id(f.community, first.id.as_bytes()) + .await + .unwrap() + .is_some()); + restricted( + f.db.insert_event(f.community, &message(&user, 9), Some(channel)) + .await, + ); + let deadlocks: i64 = sqlx::query_scalar( + "SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()", + ) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(deadlocks, 0, "include deadlocks swallowed by TTL refresh"); +} diff --git a/crates/buzz-db/src/store/user_deletion_write_guard.rs b/crates/buzz-db/src/store/user_deletion_write_guard.rs index b16b415c93b..36c804461b8 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard.rs @@ -1,30 +1,27 @@ -//! First serving integration: new stream/forum messages and agent ownership. +//! Guard shared persistent-event insertion paths and agent ownership. //! Other writes are not fenced here; real deletion must remain disabled until //! every serving path and external-work drain has been integrated. -use buzz_core::{kind, CommunityId}; +use buzz_core::CommunityId; use nostr::{Event, PublicKey}; use sqlx::{Postgres, Transaction}; use super::user_deletion_fences::{lock_exclusive, lock_shared, read_locked, FenceIdentity}; use crate::{DbError, Result}; -pub(crate) async fn guard_message( +pub(crate) async fn guard_event_author( tx: &mut Transaction<'_, Postgres>, community: CommunityId, event: &Event, ) -> Result<()> { - if !matches!( - u32::from(event.kind.as_u16()), - kind::KIND_STREAM_MESSAGE - | kind::KIND_STREAM_MESSAGE_V2 - | kind::KIND_FORUM_POST - | kind::KIND_FORUM_COMMENT - ) { - return Ok(()); - } + guard_identity(tx, community, event.pubkey).await +} - let signer = event.pubkey; +pub(crate) async fn guard_identity( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + signer: PublicKey, +) -> Result<()> { let owner = owner(tx, community, signer).await?; let mut identities = vec![ FenceIdentity::user(signer), diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs index 10f0459a7f0..7d6f39361cf 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -167,7 +167,9 @@ async fn message_writers_enforce_global_and_scoped_fences_even_without_profiles( } f.fence(&user, None).await; f.fence(&agent, Some(&user)).await; - for kind in [9, 40002, 45001, 45003] { + for kind in [ + 9, 40002, 45001, 45003, 7, 40003, 45002, 9007, 9000, 9002, 40100, 5, 45010, 1234, + ] { for keys in [&user, &agent] { restricted( f.db.insert_event(f.community, &message(keys, kind), None) @@ -432,3 +434,9 @@ async fn first_owner_assignment_waits_for_messages_and_rejects_restricted_owners .unwrap(); assert!(saved.is_none()); } + +#[path = "user_deletion_lock_order_postgres_tests.rs"] +mod lock_order_postgres_tests; + +#[path = "user_deletion_actor_lease_postgres_tests.rs"] +mod actor_lease_postgres_tests; diff --git a/crates/buzz-deletion/Cargo.toml b/crates/buzz-deletion/Cargo.toml index 6937974c844..a087e09786f 100644 --- a/crates/buzz-deletion/Cargo.toml +++ b/crates/buzz-deletion/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Durable whole-community deletion engine for Buzz" [dependencies] +nostr = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } buzz-core = { workspace = true } @@ -25,5 +26,4 @@ tokio-util = { workspace = true } uuid = { workspace = true } [dev-dependencies] -nostr = { workspace = true } sqlx = { workspace = true } diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 483ce7e90c0..e7cda21af73 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -182,20 +182,59 @@ pub async fn acquire_serving_write( community: buzz_core::CommunityId, operation: &str, ) -> Result { - acquire_serving_write_with_heartbeat(db, community, operation, heartbeat_interval()).await + acquire_serving_write_for_actor(db, community, operation, heartbeat_interval(), None).await } +/// Acquire the existing durable serving lease with authenticated actor fencing. +/// No pool connection is held during the external operation. A live actor lease +/// prevents that actor's fence from committing until the operation releases it. +pub async fn acquire_actor_serving_write( + db: &Db, + community: buzz_core::CommunityId, + operation: &str, + actor: nostr::PublicKey, +) -> Result { + acquire_serving_write_for_actor(db, community, operation, heartbeat_interval(), Some(actor)) + .await +} + +#[cfg(test)] async fn acquire_serving_write_with_heartbeat( db: &Db, community: buzz_core::CommunityId, operation: &str, heartbeat_period: Duration, +) -> Result { + acquire_serving_write_for_actor(db, community, operation, heartbeat_period, None).await +} + +async fn acquire_serving_write_for_actor( + db: &Db, + community: buzz_core::CommunityId, + operation: &str, + heartbeat_period: Duration, + actor: Option, ) -> Result { let store = store(db); let owner = default_executor_id(); - let lease = store - .acquire_serving_write_lease(community, operation, &owner, DEFAULT_LEASE_DURATION) - .await?; + let lease = match actor { + Some(actor) => { + store + .acquire_actor_serving_write_lease( + community, + operation, + &owner, + DEFAULT_LEASE_DURATION, + actor, + ) + .await? + } + None => { + store + .acquire_serving_write_lease(community, operation, &owner, DEFAULT_LEASE_DURATION) + .await? + } + }; let heartbeat_store = store.clone(); let mut heartbeat_lease = lease.clone(); let cancel = CancellationToken::new(); diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index 9a72c7ff3a4..1c3d0130f6b 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -82,6 +82,8 @@ pub enum MediaError { RelayMembershipRequired, #[error("community writes are fenced")] CommunityWriteFenced, + #[error("identity is restricted for user deletion")] + IdentityWriteFenced, #[error("media service temporarily unavailable")] ServiceUnavailable, /// An access-policy lookup failed. Legacy responses match @@ -171,9 +173,9 @@ impl MediaError { | Self::MissingTag(_) => Some(BlossomDenialKind::EvidenceRejected), // Local-policy denials: identity proved, but access refused by relay policy. // Maps to AuthorizationDenied per NIP-FI.md:764-775. - Self::RelayMembershipRequired | Self::CommunityWriteFenced => { - Some(BlossomDenialKind::AuthorizationDenied) - } + Self::RelayMembershipRequired + | Self::CommunityWriteFenced + | Self::IdentityWriteFenced => Some(BlossomDenialKind::AuthorizationDenied), Self::AuthorizationUnavailable => Some(BlossomDenialKind::AuthorizationUnavailable), _ => None, } @@ -222,9 +224,9 @@ impl IntoResponse for MediaError { ) } Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()), - Self::RelayMembershipRequired | Self::CommunityWriteFenced => { - (StatusCode::FORBIDDEN, self.to_string()) - } + Self::RelayMembershipRequired + | Self::CommunityWriteFenced + | Self::IdentityWriteFenced => (StatusCode::FORBIDDEN, self.to_string()), Self::ServiceUnavailable | Self::AuthorizationUnavailable => { (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d5420096efd..ec492a91f33 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1230,6 +1230,9 @@ async fn submit_event_authed( ) .await }; + // A delegated member must establish its verified owner before any write. + // Open-relay backfill remains optional; direct members need no owner link. + let owner_link_required = matches!(&membership, Ok(Some(_))); let nip_oa_owner = match membership { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { @@ -1250,7 +1253,18 @@ async fn submit_event_authed( } }; if let Some(owner) = nip_oa_owner { - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; + let linked = + super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; + if owner_link_required && !linked { + return SubmitOutcome::Err { + status: StatusCode::SERVICE_UNAVAILABLE, + response: api_error( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + ) + .into(), + }; + } } let auth = IngestAuth::Http { @@ -9523,6 +9537,8 @@ pub(crate) mod postgres_tests { sqlx::raw_sql(sqlx::AssertSqlSafe(format!( "CREATE SCHEMA {schema}; \ CREATE TABLE {schema}.users (LIKE public.users INCLUDING ALL); \ + CREATE TABLE {schema}.user_deletion_fences \ + (LIKE public.user_deletion_fences INCLUDING ALL); \ CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ CREATE TABLE {schema}.join_policy_acceptances \ @@ -9630,12 +9646,6 @@ pub(crate) mod postgres_tests { "{action}: the HTTP request recorded the owner" ); - sqlx::raw_sql(sqlx::AssertSqlSafe(format!( - "ALTER TABLE {schema}.users RENAME TO users_unavailable" - ))) - .execute(&admin) - .await - .expect("break the owner-to-agent lookup"); let owner_hex = owner.public_key().to_hex(); match action { "ban" => state @@ -9675,6 +9685,14 @@ pub(crate) mod postgres_tests { .expect("removal commits"); } } + // Break only the revocation lookup. The membership writes above + // now also read users while checking their event author's fence. + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "ALTER TABLE {schema}.users RENAME TO users_unavailable" + ))) + .execute(&admin) + .await + .expect("break the owner-to-agent lookup"); let revoked = state .revoke_live_access( &tenant, diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index f3dbf095654..cfdabf72bab 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -205,6 +205,7 @@ pub(super) async fn write_intent( .await { Ok(outcome) => json!(outcome), + Err(buzz_db::DbError::UserDeletionRestricted) => json!({"status":"blocked"}), Err(_) => json!({"status":"unknown","retryable":true}), } } diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c1d087dfce0..cb50b98cfc0 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -308,8 +308,19 @@ async fn share_inner( ) -> Result { // Admission first: NIP-98 + NIP-FI must fire before provider availability // check. [FI-TRACE-HTTP-INGRESS] - bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; + let (tenant, actor) = + bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; + crate::handlers::identity_write::run_http(&state, &tenant, actor, "gif_share", async { + share_authenticated(&state, body).await + }) + .await +} +#[allow(clippy::result_large_err)] +async fn share_authenticated( + state: &AppState, + body: axum::body::Bytes, +) -> Result { let Some(config) = state.config.klipy.as_ref() else { return Err( api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index f11e11026b1..b21fb7e8254 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -209,7 +209,7 @@ async fn authenticate( .await .map_err(IntoResponse::into_response)?; let tag = relay_members::extract_auth_tag_header(headers); - relay_members::enforce_relay_membership( + let delegated_owner = relay_members::enforce_relay_membership( state, &tenant, pubkey.as_bytes(), @@ -219,6 +219,14 @@ async fn authenticate( ) .await .map_err(IntoResponse::into_response)?; + if let Some(owner) = delegated_owner { + if !relay_members::materialize_nip_oa_owner(state, &tenant, &pubkey, &owner).await { + return Err(error( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + )); + } + } #[cfg(test)] crate::nip_fi_test_hooks::before_http_ban_recheck(tenant.community()).await; deny_banned_git_principal( @@ -376,17 +384,28 @@ async fn set_default_branch( })?; let snapshot = DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?; - let serving_write = buzz_deletion::acquire_serving_write( + let serving_write = buzz_deletion::acquire_actor_serving_write( &state.db, auth.tenant.community(), "git_default_branch", + auth.caller, ) .await - .map_err(|_| { - error( - StatusCode::SERVICE_UNAVAILABLE, - "community writes are fenced", - ) + .map_err(|cause| { + if matches!( + cause.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ) { + error( + StatusCode::FORBIDDEN, + "identity is restricted for user deletion", + ) + } else { + error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are fenced", + ) + } })?; serving_write.verify().await.map_err(backend)?; let (snapshot, changed) = serving_write diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 1c0c08a600b..e3f7a97878c 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -248,6 +248,22 @@ impl axum::extract::FromRequestParts> for GitAuth { (StatusCode::FORBIDDEN, "restricted: not a relay member").into_response(), )); } + Ok(crate::api::relay_members::MembershipDecision::ViaOwner(owner)) => { + if token_owner.is_none() + && !crate::api::relay_members::materialize_nip_oa_owner( + state, &tenant, &pubkey, &owner, + ) + .await + { + return Err(unavailable( + ( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + ) + .into_response(), + )); + } + } Ok(_) => {} Err(e) => { warn!(pubkey = %pubkey.to_hex(), error = %e, "git: relay membership lookup failed"); @@ -2015,15 +2031,26 @@ async fn finalize_push_inner( // An already-running receive-pack may cross the durable fence after // request admission. Revalidate immediately before object-store CAS; DB // trigger fencing alone cannot roll back an S3 pointer mutation. - let serving_write = match buzz_deletion::acquire_serving_write( + let serving_write = match buzz_deletion::acquire_actor_serving_write( &state.db, ctx.tenant.community(), "git_publish", + ctx.pusher, ) .await { Ok(guard) => guard, Err(error) => { + if matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ) { + return ( + StatusCode::FORBIDDEN, + "identity is restricted for user deletion", + ) + .into_response(); + } warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence"); return ( StatusCode::SERVICE_UNAVAILABLE, @@ -2816,6 +2843,101 @@ mod track_c_tests { } mod external_infra_minio_tests { + use super::*; + use uuid::Uuid; + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn user_fence_blocks_git_publication_without_object_pointer_or_events() { + use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; + let (state, pool) = finalize_test_state().await; + let host = format!("user-git-{}.test", Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let user = Keys::generate(); + let peer = Keys::generate(); + for phase in 0..2 { + if phase == 1 { + let request = state + .db + .user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = state.db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", std::time::Duration::from_secs(60)) + .await + .unwrap() + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + fences::insert( + &mut tx, + FenceIdentity::user(user.public_key()), + &lease, + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + } + for (index, key) in [&user, &peer].iter().enumerate() { + let repo = format!("repo-{}", Uuid::new_v4()); + let owner = key.public_key().to_hex(); + let scratch = TempDir::new().unwrap(); + let ctx = pushed_context( + &state, + community, + &host, + owner.clone(), + repo.clone(), + key.public_key(), + scratch.path(), + ) + .await; + let response = finalize_push(&state, ctx).await; + let allowed = phase == 0 || index == 1; + assert_eq!( + response.status(), + if allowed { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + ); + let pointer = crate::api::git::manifest::pointer_key(community, &owner, &repo); + assert_eq!( + state + .git_store + .get_pointer(&pointer) + .await + .unwrap() + .is_some(), + allowed + ); + let mut query = buzz_db::event::EventQuery::for_community(community); + query.kinds = Some(vec![30618]); + query.d_tag = Some(repo); + assert_eq!( + state.db.query_events(&query).await.unwrap().len(), + usize::from(allowed) + ); + } + } + } + #[tokio::test] #[ignore = "requires Postgres and MinIO"] async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d229b83eb1e..6b43163d589 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -346,7 +346,13 @@ async fn mint_invite_checked( return e.into_response(); } - match mint_invite_inner(&state, body, tenant, pubkey).await { + match crate::handlers::identity_write::run_http(&state, &tenant, pubkey, "mint_invite", async { + mint_invite_inner(&state, body, tenant.clone(), pubkey) + .await + .map_err(|error| error.into_response()) + }) + .await + { Ok(json) => json.into_response(), Err(e) => e.into_response(), } @@ -432,10 +438,28 @@ pub async fn claim_invite( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; +) -> axum::response::Response { + use axum::response::IntoResponse; + let (tenant, pubkey) = match authenticate(&state, &headers, "/api/invites/claim", &body).await { + Ok(identity) => identity, + Err(error) => return error.into_response(), + }; + crate::handlers::identity_write::run_http(&state, &tenant, pubkey, "claim_invite", async { + claim_invite_inner(&state, &tenant, pubkey, body) + .await + .map_err(|error| error.into_response()) + }) + .await + .into_response() +} - if claim_rate_limited(&state, tenant.community(), &pubkey) { +async fn claim_invite_inner( + state: &Arc, + tenant: &buzz_core::TenantContext, + pubkey: nostr::PublicKey, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + if claim_rate_limited(state, tenant.community(), &pubkey) { return Err(api_error( StatusCode::TOO_MANY_REQUESTS, "too many invite claim attempts, slow down", @@ -483,7 +507,7 @@ pub async fn claim_invite( ) .await .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; - fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; + fan_out_nip43_membership_list(tenant, state, write.snapshot).await; return match write.outcome { buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { @@ -493,7 +517,7 @@ pub async fn claim_invite( "relay member added via v2 invite" ); // NIP-43 side effects only on Joined, never on other outcomes. - if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + if let Err(e) = publish_nip43_member_added(tenant, state, &claimer_hex).await { tracing::warn!( "failed to publish NIP-43 member-added delta after v2 claim: {e}" ); @@ -562,7 +586,7 @@ pub async fn claim_invite( .await .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; let was_inserted = write.outcome; - fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; + fan_out_nip43_membership_list(tenant, state, write.snapshot).await; if was_inserted { tracing::info!( @@ -570,7 +594,7 @@ pub async fn claim_invite( member = %claimer_hex, "relay member added via invite" ); - if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + if let Err(e) = publish_nip43_member_added(tenant, state, &claimer_hex).await { tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 2592574b9b4..a9402b55a02 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -286,7 +286,12 @@ async fn upload_attribution( } fn serving_write_error(error: anyhow::Error) -> MediaError { - if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { + if matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ) { + MediaError::IdentityWriteFenced + } else if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { MediaError::CommunityWriteFenced } else { MediaError::ServiceUnavailable @@ -384,7 +389,7 @@ pub(crate) async fn upload_blob( // Post-admission: relay membership gate (NIP-43). let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); - if let Err(e) = crate::api::relay_members::enforce_relay_membership( + let delegated_owner = match crate::api::relay_members::enforce_relay_membership( &state, &ctx.tenant, auth_event.pubkey.as_bytes(), @@ -393,10 +398,21 @@ pub(crate) async fn upload_blob( token_owner, ) .await - .map(|_| ()) - .map_err(|e| membership_denial(e, strictness)) { - return e.into_response(); + Ok(owner) => owner, + Err(error) => return membership_denial(error, strictness).into_response(), + }; + if let Some(owner) = delegated_owner { + if !crate::api::relay_members::materialize_nip_oa_owner( + &state, + &ctx.tenant, + &auth_event.pubkey, + &owner, + ) + .await + { + return media_denial(MediaError::AuthorizationUnavailable, strictness).into_response(); + } } // Post-admission: rate limit and concurrency permit. @@ -449,11 +465,15 @@ async fn upload_blob_result( let strictness = auth.strictness; let attribution = upload_attribution(&state, &auth, &headers).await; - let serving_write = - buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") - .await - .map_err(serving_write_error) - .map_err(|e| media_denial(e, strictness))?; + let serving_write = buzz_deletion::acquire_actor_serving_write( + &state.db, + auth.tenant.community(), + "media_upload", + auth.auth_event.pubkey, + ) + .await + .map_err(serving_write_error) + .map_err(|e| media_denial(e, strictness))?; if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 21a54120693..59263c264ec 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -946,6 +946,51 @@ async fn run_active_audio_connection( return; } }; + // Audio is a long-lived write, including binary frames that never reach + // event ingestion. Keep one actor lease for the session, not one per packet. + // The deletion worker must disconnect/drain the session before fencing can + // complete. Losing this proof cancels the existing admission and I/O loops. + let _actor_write = match buzz_deletion::acquire_actor_serving_write( + &state.db, + tenant.community(), + "huddle_audio", + pubkey, + ) + .await + { + Ok(lease) => lease, + Err(error) => { + let restricted = matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ); + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, + nip_fi_assertion.is_some(), + if restricted { + buzz_auth::DenialClass::AuthorizationDenied + } else { + buzz_auth::DenialClass::AuthorizationUnavailable + }, + serde_json::json!({"type":"error", "message": if restricted { + "restricted: identity is restricted for user deletion" + } else { "identity write lease unavailable" }}), + ) + .await; + return; + } + }; + let actor_lost = _actor_write.lost(); + let actor_cancel = cancel.clone(); + let _actor_watcher = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move { + tokio::select! { + _ = actor_lost.cancelled() => actor_cancel.cancel(), + _ = actor_cancel.cancelled() => {}, + } + })); + // Derive parent_id_for_event from the membership admission result. // This is the channel ID that lifecycle events (48101/48102/48103) belong to. let parent_id_for_event = match &membership_admission { @@ -13317,7 +13362,7 @@ mod tests { let mut blocker = pool.begin().await.expect("blocker tx"); let locked: Vec = sqlx::query_scalar( "SELECT id FROM community_serving_write_leases \ - WHERE community_id = $1 FOR UPDATE", + WHERE community_id = $1 AND actor_pubkey IS NULL FOR UPDATE", ) .bind(community.as_uuid()) .fetch_all(&mut *blocker) @@ -15400,6 +15445,98 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn user_deletion_fence_waits_for_audio_disconnect_then_refuses_rejoin() { + use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; + let state = audio_test_state_real_db().await.unwrap(); + let (tenant, channel, key) = seed_audio_fixture(state.db.pool()).await; + let (mut client, server) = + open_admitted_audio_socket(&state, tenant.clone(), channel, &key, None).await; + let request = state + .db + .user_deletion_store() + .submit(&format!("demo:{}", uuid::Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = state.db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", std::time::Duration::from_secs(60)) + .await + .unwrap() + .unwrap(); + let identity = FenceIdentity::user(key.public_key()); + let mut tx = state.db.pool().begin().await.unwrap(); + let error = fences::insert(&mut tx, identity, &lease, None) + .await + .unwrap_err(); + assert!(error.to_string().contains("still in flight"), "{error}"); + tx.rollback().await.unwrap(); + client.close(None).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM community_serving_write_leases WHERE actor_pubkey=$1", + ) + .bind(key.public_key().to_hex()) + .fetch_one(state.db.pool()) + .await + .unwrap(); + if count == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + // The last participant leaving auto-archives the old huddle. + // Use a fresh open channel so membership/archive refusal cannot + // conceal a missing user-deletion guard on the next join. + let next_channel = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'next-huddle', 'stream', 'open', $3)", + ) + .bind(next_channel) + .bind(tenant.community().as_uuid()) + .bind(key.public_key().as_bytes()) + .execute(state.db.pool()) + .await + .unwrap(); + check_membership_for_admission( + &state, + &tenant, + next_channel, + key.public_key().as_bytes(), + None, + ) + .await + .expect("fresh channel must pass the pre-fence membership check"); + let mut tx = state.db.pool().begin().await.unwrap(); + fences::insert(&mut tx, identity, &lease, None) + .await + .unwrap(); + tx.commit().await.unwrap(); + let (frames, _) = + run_audio_auth_in(state, tenant, next_channel, None, &key, true).await; + assert!( + frames + .iter() + .any(|frame| frame.contains("restricted for user deletion")), + "{frames:?}" + ); + server.abort(); + } + /// A ban closes a live audio socket: admission binds the socket to its /// pubkey, and the ban's pod-local disconnect /// (`AppState::disconnect_pubkey_local`) closes it with a policy close. diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 772e032e75f..d104277f0f9 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -46,7 +46,7 @@ pub(crate) async fn accept( &state.relay_keypair, ) .await - .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))?; + .map_err(super::ingest::event_write_error)?; match outcome { ArtifactOutcome::Accepted(events) => { for stored in &events { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2c5a8ff5b5c..86d8ae86174 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -184,7 +184,10 @@ async fn persist_command_event( let (_, was_inserted) = buzz_db::event::insert_event_in_transaction(&mut tx, event, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: insert event: {e}")))?; + .map_err(|e| match e { + buzz_db::DbError::UserDeletionRestricted => super::ingest::event_write_error(e), + _ => IngestError::Internal(format!("error: insert event: {e}")), + })?; if was_inserted { Ok(PersistResult::Inserted(tx)) } else { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 6ee506f9e57..445c96ddb68 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -736,7 +736,36 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + } + Ok(false) => {} + Err(error) => { + let message = match error { + IngestError::Rejected(message) => message, + _ => "error: identity write lease unavailable".into(), + }; + conn.send(RelayMessage::ok(&event_id_hex, false, &message)); + } + } return; } @@ -800,13 +829,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, -) { +) -> bool { let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; match verify_result { @@ -1282,7 +1324,7 @@ async fn handle_agent_observer_event( false, &format!("invalid: {e}"), )); - return; + return false; } Err(_) => { conn.send(RelayMessage::ok( @@ -1290,7 +1332,7 @@ async fn handle_agent_observer_event( false, "error: internal error", )); - return; + return false; } } @@ -1303,20 +1345,19 @@ async fn handle_agent_observer_event( false, "invalid: observer frame timestamp outside ±5 minute freshness window", )); - return; + return false; } let route = match agent_observer_route(&event) { Ok(Some(route)) => route, Ok(None) => { // Unknown frame value — silently drop, no error to publisher. - conn.send(RelayMessage::ok(event_id_hex, true, "")); - return; + return true; } Err(message) => { reject("invalid"); conn.send(RelayMessage::ok(event_id_hex, false, &message)); - return; + return false; } }; @@ -1359,7 +1400,7 @@ async fn handle_agent_observer_event( false, "error: internal server error", )); - return; + return false; } } } @@ -1372,7 +1413,7 @@ async fn handle_agent_observer_event( false, "restricted: observer frame is not authorized for this agent owner", )); - return; + return false; } // Rate limit telemetry frames only (100/sec per agent). @@ -1386,7 +1427,7 @@ async fn handle_agent_observer_event( false, "rate-limited: observer frame rate exceeded (100/sec per agent)", )); - return; + return false; } } @@ -1412,7 +1453,7 @@ async fn handle_agent_observer_event( ); fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; - conn.send(RelayMessage::ok(event_id_hex, true, "")); + true } fn agent_observer_route(event: &Event) -> Result, String> { diff --git a/crates/buzz-relay/src/handlers/identity_write.rs b/crates/buzz-relay/src/handlers/identity_write.rs new file mode 100644 index 00000000000..0708e10bf01 --- /dev/null +++ b/crates/buzz-relay/src/handlers/identity_write.rs @@ -0,0 +1,104 @@ +//! Bound a user's multi-transaction or external action with the existing lease. +use super::ingest::{event_write_error, IngestError}; +use crate::state::AppState; +use buzz_core::TenantContext; +use nostr::PublicKey; +use std::{future::Future, sync::Arc}; + +pub(super) fn error(error: anyhow::Error) -> IngestError { + if matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ) { + event_write_error(buzz_db::DbError::UserDeletionRestricted) + } else { + tracing::warn!(%error, "identity write lease unavailable"); + IngestError::Internal("error: identity write lease unavailable".into()) + } +} + +pub(super) async fn run( + state: &Arc, + tenant: &TenantContext, + actor: PublicKey, + operation: &str, + action: impl Future>, +) -> Result { + let lease = + buzz_deletion::acquire_actor_serving_write(&state.db, tenant.community(), operation, actor) + .await + .map_err(error)?; + let result = match lease.protect_landed(action).await { + Ok((Err(denial), _)) => Ok(Err(denial)), + Ok((Ok(value), verified)) => verified.map(|()| Ok(value)).map_err(error), + Err(cause) => Err(error(cause)), + }; + let finished = lease.finish().await.map_err(error); + match result { + Ok(Ok(value)) => { + finished?; + Ok(value) + } + Ok(Err(error)) | Err(error) => { + if let Err(cleanup) = finished { + tracing::warn!( + ?cleanup, + "identity lease cleanup failed; expiry retains drain proof" + ); + } + Err(error) + } + } +} + +/// Fence a separately authenticated HTTP mutation for its complete lifetime. +#[allow(clippy::result_large_err)] // Axum handlers return their denial response directly. +pub(crate) async fn run_http( + state: &Arc, + tenant: &TenantContext, + actor: PublicKey, + operation: &str, + action: impl Future>, +) -> Result { + use axum::response::IntoResponse; + let denial = |error: anyhow::Error| { + let restricted = matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::UserDeletionRestricted) + ); + if restricted { + ( + axum::http::StatusCode::FORBIDDEN, + "restricted: identity is restricted for user deletion", + ) + .into_response() + } else { + tracing::warn!(%error, "HTTP identity write lease unavailable"); + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "identity write lease unavailable", + ) + .into_response() + } + }; + let lease = + buzz_deletion::acquire_actor_serving_write(&state.db, tenant.community(), operation, actor) + .await + .map_err(denial)?; + let result = match lease.protect_landed(action).await { + Ok((Err(response), _)) => Ok(Err(response)), + Ok((Ok(value), verified)) => verified.map(|()| Ok(value)).map_err(denial), + Err(cause) => Err(denial(cause)), + }; + let finished = lease.finish().await.map_err(denial); + match result { + Ok(Ok(value)) => { + finished?; + Ok(value) + } + Ok(Err(error)) | Err(error) => { + let _ = finished; + Err(error) + } + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 6e477072fac..07f1c886ffd 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -472,6 +472,16 @@ pub enum BanRefusal { Unavailable, } +/// Keep durable identity restrictions as client refusals on every insert path. +pub(crate) fn event_write_error(error: buzz_db::DbError) -> IngestError { + match error { + buzz_db::DbError::UserDeletionRestricted => { + IngestError::Rejected("restricted: identity is restricted for user deletion".into()) + } + other => IngestError::Internal(format!("error: {other}")), + } +} + impl IngestError { /// A ban refusal answered with `legacy` outside NIP-FI agent HTTP. pub(crate) fn ban(refusal: BanRefusal, legacy: IngestError) -> Self { @@ -2691,6 +2701,30 @@ async fn ingest_event_inner( // (commands, feedback, reports, moderation) so no write returns ahead of it. enforce_write_restriction(state, tenant, kind_u32, auth.pubkey()).await?; + super::identity_write::run( + state, + tenant, + *auth.pubkey(), + "event_ingest", + Box::pin(ingest_verified_event( + state, tracer, tenant, event, auth, now, + )), + ) + .await +} + +async fn ingest_verified_event( + state: &Arc, + tracer: &Arc, + tenant: &TenantContext, + event: Event, + auth: IngestAuth, + now: i64, +) -> Result { + let event_id_hex = event.id.to_hex(); + let kind_u32 = event_kind_u32(&event); + let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; + // 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) { @@ -3466,7 +3500,7 @@ async fn ingest_event_inner( emoji, ) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(event_write_error)? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected(format!( @@ -3558,7 +3592,7 @@ async fn ingest_event_inner( .db .create_channel_with_event(tenant.community(), create.channel_id, channel, &event) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(event_write_error)? { buzz_db::channel::ChannelCreate::Created(stored) => { metrics::counter!( @@ -3590,7 +3624,12 @@ async fn ingest_event_inner( // divide insertion and repair ownership between two relay workers. crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state) .await - .map_err(|e| IngestError::Internal(format!("error: workflow deletion failed: {e}")))? + .map_err(|e| match e.downcast_ref::() { + Some(buzz_db::DbError::UserDeletionRestricted) => { + event_write_error(buzz_db::DbError::UserDeletionRestricted) + } + _ => IngestError::Internal(format!("error: workflow deletion failed: {e}")), + })? } else if let Some(plan) = put_user { // One commit stores the event and applies its membership changes. A // failure stores nothing, so the client is told and can resend it. @@ -3606,6 +3645,9 @@ async fn ingest_event_inner( ) .await .map_err(|e| match e.downcast_ref::() { + Some(buzz_db::DbError::UserDeletionRestricted) => { + event_write_error(buzz_db::DbError::UserDeletionRestricted) + } Some(buzz_db::DbError::AccessDenied(reason)) => { IngestError::Rejected(format!("invalid: {reason}")) } @@ -3621,6 +3663,9 @@ async fn ingest_event_inner( crate::handlers::side_effects::persist_edit_metadata(tenant, &event, state) .await .map_err(|e| match e.downcast_ref::() { + Some(buzz_db::DbError::UserDeletionRestricted) => { + event_write_error(buzz_db::DbError::UserDeletionRestricted) + } Some(buzz_db::DbError::AccessDenied(reason)) => { IngestError::Rejected(format!("invalid: {reason}")) } @@ -3633,7 +3678,7 @@ async fn ingest_event_inner( .db .replace_addressable_event(tenant.community(), &event, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(event_write_error)? } else if is_parameterized_replaceable(kind_u32) { // NIP-33 parameterized replaceable — keyed by (kind, pubkey, d_tag). let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); @@ -3648,7 +3693,7 @@ async fn ingest_event_inner( .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(event_write_error)? } else if let Some(spec) = canvas_revision_spec.as_ref() { // Canvas write carrying an optimistic-concurrency precondition. Plain // canvas writes (no `expected-revision` tag) fall through to the generic @@ -3665,7 +3710,7 @@ async fn ingest_event_inner( .db .insert_canvas_head_checked(tenant.community(), &event, channel, precondition) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + .map_err(event_write_error)?; match status { buzz_db::ChannelHeadWriteStatus::RevisionMissing => { return Err(IngestError::CanvasConflict( diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index a887bd60dba..e59c6421860 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -24,6 +24,7 @@ pub mod event; pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). pub mod identity_archive; +pub(crate) mod identity_write; /// imeta tag validation helpers. pub mod imeta; /// Transport-neutral event ingestion pipeline. diff --git a/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs new file mode 100644 index 00000000000..8272d4ed893 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs @@ -0,0 +1,392 @@ +//! Expanded insertion coverage and owner-delegated HTTP regression tests. +use super::*; +use base64::Engine; +use sha2::{Digest, Sha256}; + +async fn signed_http(f: &Fixture, key: &Keys, event: &Event, owner: Option<&Keys>) -> (u16, Value) { + let body = serde_json::to_vec(event).unwrap(); + let url = format!("https://{}/events", f.tenant.host()); + let auth = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(), + ]) + .sign_with_keys(key) + .unwrap(); + let mut request = reqwest::Client::new() + .post(format!("{}/events", f.url)) + .header("host", f.tenant.host()) + .header("content-type", "application/json") + .header( + "authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&auth).unwrap()) + ), + ) + .body(body); + if let Some(owner) = owner { + request = request.header( + "x-auth-tag", + buzz_sdk::nip_oa::compute_auth_tag(owner, &key.public_key(), "").unwrap(), + ); + } + let response = request.send().await.unwrap(); + (response.status().as_u16(), response.json().await.unwrap()) +} + +async fn direct_member(f: &Fixture, key: &Keys) { + sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING") + .bind(f.tenant.community().as_uuid()).bind(key.public_key().to_hex()) + .execute(&f.pool).await.unwrap(); +} + +fn action(key: &Keys, channel: Uuid, kind: u16, content: &str, extra: Vec>) -> Event { + let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; + tags.extend(extra.into_iter().map(|tag| Tag::parse(tag).unwrap())); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(key) + .unwrap() +} + +async fn snapshot(f: &Fixture) -> Vec { + let mut rows = Vec::new(); + for table in [ + "events", + "channels", + "channel_members", + "reactions", + "thread_metadata", + "event_mentions", + "operator_listener_outbox", + "artifact_heads", + "artifact_revisions", + "workflows", + ] { + let query = format!("SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) FROM {table} t WHERE community_id=$1"); + rows.push( + sqlx::query_scalar(sqlx::AssertSqlSafe(query)) + .bind(f.tenant.community().as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(), + ); + } + rows +} + +#[path = "user_deletion_lifecycle_postgres_tests.rs"] +mod lifecycle_postgres_tests; + +/// Each case must succeed before fencing, then leave every relevant table +/// unchanged on rejection. Both real HTTP routing and the WS EVENT handler run. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn expanded_actions_reject_fenced_humans_and_agents_without_partial_writes() { + let f = Fixture::new().await; + let mut fixtures = Vec::new(); + for websocket in [false, true] { + for key in [&f.user, &f.agent, &f.peer] { + let channel = create_channel(&f.state, &f.tenant, key, &[]).await; + let root = action(key, channel, 9, "edit target", vec![]); + let post = action(key, channel, 45001, "vote target", vec![]); + assert!(f.send(key, &root, websocket).await.0); + assert!(f.send(key, &post, websocket).await.0); + fixtures.push((websocket, key, channel, root, post)); + } + } + for fenced in [false, true] { + if fenced { + f.fence().await; + } + for (websocket, key, channel, root, post) in &fixtures { + let text = Uuid::new_v4().to_string(); + let member = Keys::generate().public_key().to_hex(); + let new_channel = Uuid::new_v4(); + let dm = EventBuilder::new(Kind::Custom(41010), text.clone()) + .tags([Tag::parse(["p", &Keys::generate().public_key().to_hex()]).unwrap()]) + .sign_with_keys(key) + .unwrap(); + let cases = vec![ + ("open DM", dm), + ( + "reaction", + action( + key, + *channel, + 7, + if fenced { "🚀" } else { "👍" }, + vec![vec!["e".into(), root.id.to_hex()]], + ), + ), + ( + "edit", + action( + key, + *channel, + 40003, + &text, + vec![vec!["e".into(), root.id.to_hex()]], + ), + ), + ( + "vote", + action( + key, + *channel, + 45002, + "+", + vec![ + vec!["e".into(), post.id.to_hex()], + vec!["nonce".into(), text.clone()], + ], + ), + ), + ("canvas", action(key, *channel, 40100, &text, vec![])), + ( + "settings", + action( + key, + *channel, + 9002, + "", + vec![ + vec!["name".into(), text.clone()], + vec!["topic".into(), text.clone()], + ], + ), + ), + ( + "ttl settings", + action( + key, + *channel, + 9002, + "", + vec![vec![ + "ttl".into(), + if fenced { "7200" } else { "3600" }.into(), + ]], + ), + ), + ( + "add member", + action( + key, + *channel, + 9000, + "", + vec![vec!["p".into(), member, "member".into()]], + ), + ), + ( + "create channel", + action( + key, + new_channel, + 9007, + "", + vec![vec!["name".into(), text.clone()]], + ), + ), + ( + "artifact", + action( + key, + *channel, + 45010, + &serde_json::json!({"text": text}).to_string(), + vec![ + vec!["ar".into(), "1".into()], + vec!["d".into(), Uuid::new_v4().to_string()], + vec!["type".into(), "buzz.task".into()], + vec!["op".into(), "create".into()], + vec!["title".into(), "Task".into()], + ], + ), + ), + ]; + for (name, event) in cases { + let should_accept = !fenced || key.public_key() == f.peer.public_key(); + let before = snapshot(&f).await; + let (accepted, reason) = f.send(key, &event, *websocket).await; + assert_eq!( + accepted, should_accept, + "{name} fenced={fenced} ws={websocket}: {reason}" + ); + assert_eq!( + f.state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_some(), + should_accept, + "{name}" + ); + if !should_accept { + assert_eq!( + reason, "restricted: identity is restricted for user deletion", + "{name}" + ); + assert_eq!(snapshot(&f).await, before, "{name} must roll back domain writes, counters, mentions, and notifications"); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn delegated_http_refuses_failed_owner_link_but_preserves_independent_members() { + let f = Fixture::configured(true).await; + direct_member(&f, &f.user).await; + direct_member(&f, &f.peer).await; + // All have channel membership, but only the last has independent relay membership. + let fresh = Keys::generate(); + let allowed = Keys::generate(); + let independent = Keys::generate(); + let channel = create_channel( + &f.state, + &f.tenant, + &f.peer, + &[ + &["p", &fresh.public_key().to_hex()], + &["p", &allowed.public_key().to_hex()], + &["p", &independent.public_key().to_hex()], + ], + ) + .await; + direct_member(&f, &independent).await; + f.fence().await; + for (key, owner, accepted) in [ + (&fresh, &f.user, false), + (&allowed, &f.peer, true), + (&independent, &f.user, true), + ] { + let event = EventBuilder::new(Kind::Custom(9), Uuid::new_v4().to_string()) + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .sign_with_keys(key) + .unwrap(); + let (status, body) = signed_http(&f, key, &event, Some(owner)).await; + assert_eq!( + status < 300 && body["accepted"] == true, + accepted, + "{status}: {body}" + ); + assert_eq!( + f.state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_some(), + accepted + ); + let stored: Option> = sqlx::query_scalar( + "SELECT agent_owner_pubkey FROM users WHERE community_id=$1 AND pubkey=$2", + ) + .bind(f.tenant.community().as_uuid()) + .bind(key.public_key().as_bytes()) + .fetch_optional(&f.pool) + .await + .unwrap() + .flatten(); + if key.public_key() == allowed.public_key() { + assert_eq!( + stored.as_deref(), + Some(f.peer.public_key().as_bytes().as_slice()) + ); + } else { + assert!( + stored.is_none(), + "refused/independent identity must not be reassigned" + ); + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn concurrent_expanded_writes_wait_for_fencing_commit_or_rollback() { + for commit in [false, true] { + let f = Arc::new(Fixture::new().await); + let channel = create_channel(&f.state, &f.tenant, &f.user, &[]).await; + let gate = f.pending_fence().await; + let mut tasks = Vec::new(); + for (index, kind) in [9, 40100, 9002, 9000, 9007].into_iter().enumerate() { + let f = f.clone(); + let channel = if kind == 9007 { + Uuid::new_v4() + } else { + channel + }; + let tags = match kind { + 9002 => vec![vec!["ttl".into(), "3600".into()]], + 9000 => vec![vec![ + "p".into(), + Keys::generate().public_key().to_hex(), + "member".into(), + ]], + 9007 => vec![vec!["name".into(), Uuid::new_v4().to_string()]], + _ => vec![], + }; + let event = action(&f.user, channel, kind, "concurrent test", tags); + tasks.push(tokio::spawn(async move { + let result = f.send(&f.user, &event, index % 2 == 0).await; + (event, result) + })); + } + // Wait for an actual database lock waiter, not a guessed sleep. + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid WHERE a.datname=current_database() AND l.locktype='advisory' AND NOT l.granted)") + .fetch_one(&f.pool).await.unwrap(); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("a real writer must wait on the fence"); + assert!(tasks.iter().all(|task| !task.is_finished())); + assert!( + f.send(&f.peer, &f.message(&f.peer, 9, None), false).await.0, + "unrelated user must proceed" + ); + if commit { + gate.commit().await.unwrap(); + } else { + gate.rollback().await.unwrap(); + } + for task in tasks { + let (event, (accepted, reason)) = tokio::time::timeout(Duration::from_secs(8), task) + .await + .unwrap() + .unwrap(); + assert_eq!(accepted, !commit, "kind {}: {reason}", event.kind.as_u16()); + assert_eq!( + f.state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_some(), + !commit + ); + } + let deadlocks: i64 = sqlx::query_scalar( + "SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()", + ) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + deadlocks, 0, + "no deadlocks, including swallowed TTL-refresh failures" + ); + } +} + +#[path = "user_deletion_remaining_postgres_tests.rs"] +mod remaining_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs new file mode 100644 index 00000000000..5763b184864 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs @@ -0,0 +1,116 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn conditional_canvas_and_deletion_actions_enforce_sender_fences() { + for websocket in [false, true] { + for actor in [0, 1, 2] { + let f = Fixture::new().await; + let key = match actor { + 0 => &f.user, + 1 => &f.agent, + _ => &f.peer, + }; + let mut phases = Vec::new(); + // Prepare both targets before fencing, so authorization/target checks + // cannot accidentally make an unexercised guard look correct. + for _ in 0..2 { + let channel = create_channel(&f.state, &f.tenant, key, &[]).await; + let root = action(key, channel, 9, "delete me", vec![]); + assert!(f.send(key, &root, websocket).await.0); + let workflow = Uuid::new_v4(); + f.state + .db + .ensure_user(f.tenant.community(), key.public_key().as_bytes()) + .await + .unwrap(); + sqlx::query("INSERT INTO workflows (community_id,id,name,owner_pubkey,channel_id,definition,definition_hash) VALUES ($1,$2,'fence-test',$3,$4,'{}','\\x00')") + .bind(f.tenant.community().as_uuid()).bind(workflow).bind(key.public_key().as_bytes()).bind(channel) + .execute(&f.pool).await.unwrap(); + let definition = action( + key, + channel, + 30620, + "{}", + vec![vec!["d".into(), workflow.to_string()]], + ); + f.state + .db + .replace_parameterized_event( + f.tenant.community(), + &definition, + &workflow.to_string(), + Some(channel), + ) + .await + .unwrap(); + phases.push((channel, root, workflow)); + } + for (phase, (channel, root, workflow)) in phases.into_iter().enumerate() { + if phase == 1 { + f.fence().await; + } + let accepted = phase == 0 || actor == 2; + let cases = [ + ( + "conditional canvas", + action( + key, + channel, + 40100, + "canvas", + vec![vec!["expected-revision".into(), "none".into()]], + ), + ), + ( + "message deletion", + action( + key, + channel, + 5, + "", + vec![vec!["e".into(), root.id.to_hex()]], + ), + ), + ( + "workflow deletion", + action( + key, + channel, + 5, + "", + vec![vec![ + "a".into(), + format!("30620:{}:{workflow}", key.public_key()), + ]], + ), + ), + ("channel deletion", action(key, channel, 9008, "", vec![])), + ]; + for (name, event) in cases { + let before = snapshot(&f).await; + let (actual, reason) = f.send(key, &event, websocket).await; + assert_eq!( + actual, accepted, + "{name} actor={actor} phase={phase} ws={websocket}: {reason}" + ); + if !accepted { + assert_eq!( + reason, "restricted: identity is restricted for user deletion", + "{name}" + ); + assert_eq!( + snapshot(&f).await, + before, + "{name} changed state despite refusal" + ); + } else if name == "workflow deletion" { + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workflows WHERE community_id=$1 AND id=$2)") + .bind(f.tenant.community().as_uuid()).bind(workflow).fetch_one(&f.pool).await.unwrap(); + assert!(!exists, "positive control must really delete the workflow"); + } + } + } + } + } +} diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs index e6627646148..94d0808d4d8 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -25,9 +25,20 @@ struct Fixture { impl Fixture { async fn new() -> Self { - let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + Self::configured(false).await + } + + async fn configured(strict: bool) -> Self { + let mut state = crate::api::bridge::postgres_tests::bridge_handler_test_state() .await .expect("Postgres and Redis"); + if strict { + let state = Arc::make_mut(&mut state); + let config = Arc::make_mut(&mut state.config); + config.require_auth_token = true; + config.require_relay_membership = true; + config.allow_nip_oa_auth = true; + } let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); @@ -99,6 +110,10 @@ impl Fixture { } async fn fence(&self) { + self.pending_fence().await.commit().await.unwrap(); + } + + async fn pending_fence(&self) -> sqlx::Transaction<'static, sqlx::Postgres> { let request = self .state .db @@ -131,7 +146,7 @@ impl Fixture { fences::insert(&mut tx, agent, &lease, Some(self.user.public_key())) .await .unwrap(); - tx.commit().await.unwrap(); + tx } async fn send(&self, key: &Keys, event: &Event, websocket: bool) -> (bool, String) { @@ -140,7 +155,7 @@ impl Fixture { if let crate::connection::AuthState::Authenticated(ctx) = &mut *conn.auth_state.lock().unwrap() { - ctx.scopes = vec![buzz_auth::Scope::MessagesWrite]; + ctx.scopes = buzz_auth::Scope::all_known(); // Intentionally no cached owner: the write must use PostgreSQL. ctx.agent_owner_pubkey = None; } @@ -233,3 +248,6 @@ async fn http_and_websocket_messages_honor_user_and_agent_fences() { ); } } + +#[path = "user_deletion_expanded_postgres_tests.rs"] +mod expanded_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs new file mode 100644 index 00000000000..7f749395618 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs @@ -0,0 +1,225 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn replacements_workflows_and_typing_refuse_fenced_users_and_agents() { + let f = Fixture::new().await; + let mut channels = Vec::new(); + for key in [&f.user, &f.agent, &f.peer] { + channels.push(create_channel(&f.state, &f.tenant, key, &[]).await); + } + for phase in 0..2 { + if phase == 1 { + f.fence().await; + } + for websocket in [false, true] { + for (actor, key) in [&f.user, &f.agent, &f.peer].into_iter().enumerate() { + let expected = phase == 0 || actor == 2; + let channel = channels[actor]; + let nonce = Uuid::new_v4().to_string(); + let timestamp = nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + phase * 4 + u64::from(websocket), + ); + let cases = [ + (0, format!("{{\"name\":\"{nonce}\"}}"), vec![]), + (10030, String::new(), vec![vec!["emoji".into(), "probe".into(), "https://example.invalid/probe.png".into()]]), + (30030, String::new(), vec![vec!["d".into(), nonce.clone()], vec!["emoji".into(), "probe".into(), "https://example.invalid/probe.png".into()]]), + (10003, String::new(), vec![vec!["e".into(), "ab".repeat(32)]]), + (30620, "name: Gate probe\ntrigger:\n on: webhook\nsteps:\n - id: noop\n action: send_message\n text: Never invoked\n".into(), vec![vec!["d".into(), Uuid::new_v4().to_string()], vec!["h".into(), channel.to_string()]]), + (20002, String::new(), vec![vec!["h".into(), channel.to_string()]]), + ]; + for (kind, content, tags) in cases { + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .custom_created_at(timestamp) + .sign_with_keys(key) + .unwrap(); + let before = snapshot(&f).await; + let (accepted, reason) = f.send(key, &event, websocket).await; + assert_eq!( + accepted, expected, + "kind {kind}, actor {actor}, WS {websocket}: {reason}" + ); + if !expected { + assert_eq!( + reason, + "restricted: identity is restricted for user deletion" + ); + assert_eq!(snapshot(&f).await, before); + } + assert_eq!( + f.state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_some(), + expected && kind != 20002 + ); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn existing_workflow_actions_refuse_fenced_owners() { + let f = Fixture::new().await; + let action = buzz_workflow::ActionDef::Delay { + duration: "0s".into(), + }; + let step = buzz_workflow::Step { + id: "probe".into(), + name: None, + if_expr: None, + timeout_secs: None, + action: action.clone(), + }; + let mut runs = Vec::new(); + for key in [&f.user, &f.agent, &f.peer] { + f.state + .db + .ensure_user(f.tenant.community(), key.public_key().as_bytes()) + .await + .unwrap(); + let workflow = f + .state + .db + .create_workflow( + f.tenant.community(), + Some(f.channel), + key.public_key().as_bytes(), + "existing", + "{}", + &[0; 32], + ) + .await + .unwrap(); + runs.push( + f.state + .db + .create_workflow_run(f.tenant.community(), workflow, None, None) + .await + .unwrap(), + ); + } + for phase in 0..2 { + if phase == 1 { + f.fence().await; + } + for (index, run) in runs.iter().enumerate() { + let result = buzz_workflow::executor::dispatch_action( + &step, + &action, + &f.state.workflow_engine, + f.tenant.community(), + *run, + &Default::default(), + ) + .await; + assert_eq!(result.is_ok(), phase == 0 || index == 2, "{result:?}"); + if phase == 1 && index != 2 { + assert!(result + .unwrap_err() + .to_string() + .contains("restricted for user deletion")); + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn upload_routes_check_actor_before_reading_body() { + let f = Fixture::new().await; + // Deliberately unsupported audio proves admission reached body validation + // before fencing; after fencing the very same signed input must be 403. + let body = b"ID3\x04\x00\x00\x00\x00\x00\x00"; + for phase in 0..2 { + if phase == 1 { + f.fence().await; + } + for path in ["/upload", "/media/upload"] { + for (index, key) in [&f.user, &f.agent, &f.peer].iter().enumerate() { + let sha = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::Custom(24242), Uuid::new_v4().to_string()) + .tags([ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["server", f.tenant.host()]).unwrap(), + Tag::parse(["x", &sha]).unwrap(), + Tag::parse([ + "expiration", + &(nostr::Timestamp::now().as_secs() + 55).to_string(), + ]) + .unwrap(), + ]) + .sign_with_keys(key) + .unwrap(); + let response = reqwest::Client::new() + .put(format!("{}{path}", f.url)) + .header("host", f.tenant.host()) + .header("content-type", "audio/mpeg") + .header("x-sha-256", &sha) + .header( + "authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&event).unwrap()) + ), + ) + .body(body.to_vec()) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let text = response.text().await.unwrap(); + assert_eq!( + status, + if phase == 1 && index != 2 { 403 } else { 415 }, + "{path} {index}: {text}" + ); + if status == 403 { + assert!(text.contains("restricted for user deletion"), "{text}"); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn personal_read_markers_refuse_fenced_actor_even_for_idempotent_retries() { + use buzz_db::personal_read::{IntentOutcome, ReadIntent, ReadTarget}; + let f = Fixture::new().await; + let message = f.message(&f.peer, 9, None); + assert!(f.send(&f.peer, &message, false).await.0); + let intent = ReadIntent::MarkThrough { + target: ReadTarget { + channel_id: f.channel, + root_id: None, + }, + message_id: message.id.to_hex(), + }; + for phase in 0..2 { + if phase == 1 { + f.fence().await; + } + for (index, key) in [&f.user, &f.agent, &f.peer].iter().enumerate() { + let result = f + .state + .db + .apply_personal_read_intent(f.tenant.community(), &key.public_key(), &intent) + .await; + if phase == 1 && index != 2 { + assert!( + matches!(result, Err(buzz_db::DbError::UserDeletionRestricted)), + "{result:?}" + ); + } else { + assert_eq!(result.unwrap(), IntentOutcome::Applied); + } + } + } +} diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 90a6a02e020..dd5d2f0bf1b 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -550,14 +550,30 @@ pub async fn dispatch_action( // Revalidate the durable community fence immediately before every external // side effect (message publish, webhook, delay/resume). A storage failure is // a denial, never permission to continue. - let serving_write = - buzz_deletion::acquire_serving_write(&engine.db, community_id, "workflow_action") - .await - .map_err(|error| { - WorkflowError::WebhookError(format!( - "community write fence rejected workflow side effect: {error}" - )) - })?; + let owner_run = engine + .db + .get_workflow_run(community_id, run_id) + .await + .map_err(|e| WorkflowError::WebhookError(format!("workflow run lookup failed: {e}")))?; + let owner_workflow = engine + .db + .get_workflow(community_id, owner_run.workflow_id) + .await + .map_err(|e| WorkflowError::WebhookError(format!("workflow owner lookup failed: {e}")))?; + let owner = nostr::PublicKey::from_slice(&owner_workflow.owner_pubkey) + .map_err(|e| WorkflowError::Unauthorized(format!("invalid workflow owner: {e}")))?; + let serving_write = buzz_deletion::acquire_actor_serving_write( + &engine.db, + community_id, + "workflow_action", + owner, + ) + .await + .map_err(|error| { + WorkflowError::WebhookError(format!( + "community write fence rejected workflow side effect: {error}" + )) + })?; serving_write.verify().await.map_err(|error| { WorkflowError::WebhookError(format!("community write lease lost: {error}")) diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md index b9c3d2f47e3..9bf946a9ce3 100644 --- a/docs/user-deletion-message-fencing.md +++ b/docs/user-deletion-message-fencing.md @@ -1,44 +1,69 @@ -# User-deletion fencing: message integration +# User-deletion fencing: serving integration -This is the first serving integration after fence storage (PR #8632). It does not enable the real user-deletion worker. Do not install production fences until all required serving paths, fencing inventory, and external-work draining are integrated and deployed on every writer. +This extends the restriction storage from PR #8632 to serving writes. It does not enable the real user-deletion worker. Do not install production fences until inventory, quiescing, worker integration and every writer deployment are complete. + +## Identity contract + +Human restrictions apply to a public key across hosted communities. Agent restrictions apply to a public key within one community. Serving code checks the signer's global and scoped restrictions. It locks the stored owner's identity for coordination; the owner's restriction is not an additional authorization check for an independently admitted agent. + +Restrictions survive ordinary profile deletion. An independently admitted agent with no stored owner or own restriction remains allowed, even if it supplies an optional owner credential. When HTTP admission depends on delegation, required owner linking must succeed before ingestion. ## Coverage -| Operation | This change | +| Surface | Protection | | --- | --- | -| New stream messages and replies, kinds 9 and 40002 | Guarded inside event persistence | -| New forum posts and comments, kinds 45001 and 45003 | Guarded inside event persistence | -| HTTP `/events` and WebSocket `EVENT` for these kinds | Same guard and fixed rejection | -| Agent's first owner assignment through `set_agent_owner` | Coordinates with message locks; refuses restricted owners and identities | -| Other kinds, including edits, reactions, profiles, commands, ephemeral typing, and gift wraps | Follow-up work | -| Uploads, git operations, agent execution, and already-started external work | Follow-up work | +| Persistent events, including replies, reactions, edits, channel changes and deletion commands | Shared identity locks in persistence, plus an actor lease around ingestion and its side effects | +| Replaceable profiles, emoji collections, bookmarks and workflow definitions | Guarded replacement functions and ingestion actor lease | +| Personal read-state mutations | Identity guard in the write transaction, including idempotent retries | +| WebSocket ephemeral activity and observer frames; HTTP typing | Actor lease through publication and final verification | +| Uploads on both routes | Actor lease around the upload, including final verification | +| Git publication and default-branch mutation | Actor lease around the protected publication/mutation | +| Workflow actions | Durable workflow owner lookup and actor lease before each action | +| Invitation mint/claim and GIF share | Actor lease around the authenticated mutation | +| Huddle audio | One actor lease per session; lease loss cancels the connection | + +Ordinary reads remain available. Affected-user checks, such as Other User adding Requested User to a channel, are separate work. This is not a claim that all background jobs or external agent processes are cancelled. + +## Transactional writes -The guard is called by both event insertion implementations in `buzz-db/src/store/event.rs`. It runs before the event INSERT, thread metadata, counters, mentions, and post-commit publication. It only applies to the four kinds above. Shared locks remain held until the caller's transaction commits or rolls back. There is no restriction cache and no separate transaction holding the message's identity locks. +1. Acquire the existing shared community deletion guard. +2. Read the signer's stored owner in this community. +3. Acquire shared global signer/owner locks in canonical order, then the scoped signer lock. +4. Read ownership again. If it changed while waiting, refuse the operation for retry rather than extending the lock set out of order. +5. Read the signer's global and scoped restriction rows. Refuse a restricted signer. +6. Write the event and transactional derived records; commit or rollback releases the locks. -## A normal message +The restriction reads are ordinary indexed reads. No restriction cache is used. First-owner assignment coordinates through the scoped exclusive agent lock and refuses restricted prospective owners and agents. -1. The transports authenticate and validate the event through their existing paths. The shared ingest path requires the event signer to equal the authenticated identity for these message kinds. -2. The database writer starts its existing transaction and acquires the shared community deletion lock. -3. Read `users.agent_owner_pubkey` for the signer in this community. Do not trust an owner in a session cache or event tag. -4. Acquire shared identity locks in the existing canonical order: the global signer key, the verified owner's global key when present, and the signer's scoped community key. -5. Read ownership again. If it changed while waiting, reject with a retry message; release the transaction instead of acquiring more locks out of order. -6. Read the signer's global and community-scoped restriction rows. Neither lookup reads the owner's restriction. Refuse with `restricted: identity is restricted for user deletion` if either exists. A missing profile does not remove a restriction. -7. Persist the message and its transactional derived records, then commit and release all locks. Continue the existing post-commit publication. +## Operations spanning transactions or external effects -A global signer lock and lookup are retained even for agents because role/profile rows are mutable. The scoped signer lookup is retained even without an ownership row because permanent restrictions must survive profile deletion. This is two exact indexed lookups, not a new batch-read API. The guard reuses the internal locked-read helper after acquiring the full lock set once. +The existing `community_serving_write_leases` table gains nullable `actor_pubkey`. Actor lease acquisition checks restrictions while holding shared identity locks, then records the lease in that same transaction. Heartbeats and final verification also check restrictions. No database connection is retained for the entire external operation. -## Ownership assignment +Fence insertion validates the approved request and worker lease, acquires exclusive identity locks, then checks for live actor leases matching the restriction's scope. If one exists, insertion returns `identity writes are still in flight; retry fencing`. The caller must roll back the transaction and retry later; it must not sleep while holding the exclusive locks. -`set_agent_owner_with_operation` remains the first-owner-wins conditional update. Before that update, its existing transaction acquires shared locks for the prospective owner and global agent key, then the agent's scoped exclusive lock. It checks the prospective owner's global restriction and the agent's own restrictions. The exclusive agent lock prevents assignment from changing ownership while a message uses the old relationship. This covers both existing public setter entry points, including authorization callers; it is not a claim that every agent lifecycle operation is now fenced. +This handles both orders: an admitted operation leaves a lease that prevents fencing, or fencing commits first and the new operation is rejected. It reuses the existing lease heartbeat, expiry and cancellation mechanisms. It does not provide a remote storage service with transactional rollback or stop a remote effect already dispatched before lease loss. -## Fencing and in-flight work +A live audio session can keep its lease alive. The future deletion workflow must quiesce/disconnect sessions before fencing can complete. This PR does not add that orchestration. A worker installing human and owned-agent restrictions must still do so atomically under the complete ordered lock set and save its checkpoint in the same transaction. -The future fencing worker must lock the request, take the user's exclusive identity lock, discover owned agents under that lock, acquire their exclusive scoped locks, and commit all restrictions and the checkpoint together. If a message owns a shared lock first, the worker waits until that message transaction ends. If the worker owns its exclusive lock first, the message waits and then sees the committed restriction. +## Deployment and performance -This transaction only protects message persistence. It does not drain uploads or promise that all post-commit delivery has finished when a fence commits. Those are separate later stages. +Apply migration `0075_serving_write_actor.sql` before deploying code that reads/writes the new column. Desired-schema installations obtain the column and partial actor index from the table definition. Old community-only writers can leave the column NULL, but cannot protect user deletion; deploy the actor-aware code to every writer before enabling real fencing. + +The actor lease adds database writes and checks to serving operations, including ephemeral events. No production throughput or latency benchmark is claimed. Schema rollout and serving-path overhead need review. ## Validation -Database regressions cover both insertion APIs, all four message kinds, human restrictions across communities, agent restrictions within one community, removal/recreation of profiles, both fencing race directions, owner changes while a sender waits, and assignment blocked by an active message or restricted owner. They observe PostgreSQL lock waiters before releasing a lock rather than depending on a sleep to manufacture the race. +The local relay simulation uses synthetic identities across three communities and real HTTP/WebSocket requests, PostgreSQL, Redis and MinIO. The fresh matrix ran 510 supported cases before fencing and 510 afterward: 272 expected refusals and 238 allowed controls after fencing. Fifteen HTTP presence observations per phase are unsupported and excluded. These are action/actor/community/transport combinations, not 510 distinct features. + +The matrix covers reactions, edits, votes, canvas, channel settings/TTL/membership/creation, artifacts, profiles, emoji lists/sets, bookmarks, workflow creation, typing, WebSocket presence, both upload routes and read-state. It verifies persistence for event writes. The same agent remains allowed in a community where it belongs to a different owner. + +Separate production-path regressions cover messages and reply counters, conditional canvas, message/workflow/channel deletion with unchanged database snapshots on refusal, existing workflow actions, HTTP delegated ownership, actor lease races, and audio disconnect/fence/new-join. Git tests exercise finalization with real repositories and local MinIO, including publication records and unchanged storage on refusal; they are not a complete remote CLI push test. + +Reproduce the focused PostgreSQL checks with isolated test PostgreSQL and Redis configured: + +```sh +scripts/postgres-test-run.sh -p buzz-db -p buzz-deletion -p buzz-relay --lib \ + -E 'test(/user_deletion/)' --test-threads 2 --no-fail-fast +``` -The relay regression starts a local listening relay, sends HTTP requests over TCP, and exercises the WebSocket EVENT handler, checks the fixed rejection, verifies no rejected event exists, and confirms rejected replies leave thread counters unchanged. The peer remains able to send after the requested user and agent are fenced. +The live experiment uses approved synthetic requests and production fence helpers. It does not execute a real deletion inventory/workflow, delete user data, or validate production authentication/deployment at fleet scale. Repository-wide validation status is recorded in the PR; a passing live matrix alone is not a passing full suite. diff --git a/migrations/0075_serving_write_actor.sql b/migrations/0075_serving_write_actor.sql new file mode 100644 index 00000000000..4687517e212 --- /dev/null +++ b/migrations/0075_serving_write_actor.sql @@ -0,0 +1,10 @@ +SET LOCAL lock_timeout = '5s'; + +-- Additive: legacy community-only writers may leave this NULL. Roll out actor +-- admission to every relay before enabling real user deletion. Existing lease +-- expiry/reaping and whole-community deletion retain their current behavior. +ALTER TABLE community_serving_write_leases ADD COLUMN actor_pubkey TEXT; + +CREATE INDEX community_serving_write_leases_actor_active + ON community_serving_write_leases (actor_pubkey, community_id, lease_until) + WHERE actor_pubkey IS NOT NULL; diff --git a/schema/tables/public/community_serving_write_leases.sql b/schema/tables/public/community_serving_write_leases.sql index 6a905fe6b47..bfed72ebfb6 100644 --- a/schema/tables/public/community_serving_write_leases.sql +++ b/schema/tables/public/community_serving_write_leases.sql @@ -2,6 +2,8 @@ CREATE TABLE community_serving_write_leases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), community_id UUID NOT NULL REFERENCES communities(id), operation TEXT NOT NULL, + -- Optional authenticated actor; no profile FK, survives profile cleanup. + actor_pubkey TEXT, owner TEXT NOT NULL, generation BIGINT NOT NULL DEFAULT 1 CHECK (generation > 0), -- Community fence generation observed when this lease was acquired. @@ -12,3 +14,7 @@ CREATE TABLE community_serving_write_leases ( ); CREATE INDEX community_serving_write_leases_active ON community_serving_write_leases (community_id, lease_until); + +CREATE INDEX community_serving_write_leases_actor_active + ON community_serving_write_leases (actor_pubkey, community_id, lease_until) + WHERE actor_pubkey IS NOT NULL; From 0fe7f9c3d429572ae5a239ac8d322e6992e48fcc Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Thu, 29 Oct 2026 16:47:20 -0400 Subject: [PATCH 03/11] Resolve serving lease migration collision Signed-off-by: Ravneet Arora --- crates/buzz-db/src/runtime/migration.rs | 35 ++++++++++++++++--- crates/buzz-db/src/store/deletion.rs | 2 +- docs/user-deletion-message-fencing.md | 2 +- ...actor.sql => 0076_serving_write_actor.sql} | 0 4 files changed, 33 insertions(+), 6 deletions(-) rename migrations/{0075_serving_write_actor.sql => 0076_serving_write_actor.sql} (100%) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7a1a551b7c0..6d97075ed32 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -776,7 +776,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 75); + assert_eq!(migrations.len(), 76); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -2289,15 +2289,42 @@ mod postgres_tests { assert!(!migration.fence_attachments.is_empty()); assert!(!migration.registry_rows.is_empty()); - for (table, definition) in &migration.tables { + let actor_migration = MIGRATOR + .iter() + .find(|migration| migration.version == 76) + .expect("actor serving lease migration"); + assert!(actor_migration + .sql + .as_str() + .contains("ALTER TABLE community_serving_write_leases ADD COLUMN actor_pubkey TEXT")); + let actor_surface = surface(actor_migration.sql.as_ref()); + assert_eq!(actor_surface.indexes.len(), 1); + for index in &actor_surface.indexes { + assert!( + schema.indexes.contains(index), + "actor lease index differs: {index}" + ); + } + for (table, original_definition) in &migration.tables { + // 0076 adds this nullable field; preserve parity for every other + // column in the original serving-lease table. + let definition = if table == "community_serving_write_leases" { + original_definition.replacen( + "operation text not null, ", + "operation text not null, actor_pubkey text, ", + 1, + ) + } else { + original_definition.clone() + }; let in_schema = schema .tables .get(table) .unwrap_or_else(|| panic!("schema.sql is missing deletion table {table}")); if table != "community_deletion_requests" && table != "community_deletion_approvals" { assert_eq!( - in_schema, definition, - "schema.sql definition of {table} drifted from migration 0029" + in_schema, &definition, + "schema.sql definition of {table} drifted from its migrations" ); } } diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 1809b25a5ae..a6bd057f40f 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -4651,7 +4651,7 @@ mod postgres_tests { // include the additive serving-lease shape required by current writers. let mut schema_tx = db.pool.begin().await.unwrap(); sqlx::raw_sql(include_str!( - "../../../../migrations/0075_serving_write_actor.sql" + "../../../../migrations/0076_serving_write_actor.sql" )) .execute(&mut *schema_tx) .await diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md index 46c15537ea5..84c847d6d4f 100644 --- a/docs/user-deletion-message-fencing.md +++ b/docs/user-deletion-message-fencing.md @@ -47,7 +47,7 @@ A live audio session can keep its lease alive. The future deletion workflow must ## Deployment and performance -Apply migration `0075_serving_write_actor.sql` before deploying code that reads/writes the new column. Desired-schema installations obtain the column and partial actor index from the table definition. Old community-only writers can leave the column NULL, but cannot protect user deletion; deploy the actor-aware code to every writer before enabling real fencing. +Apply migration `0076_serving_write_actor.sql` before deploying code that reads/writes the new column. Desired-schema installations obtain the column and partial actor index from the table definition. Old community-only writers can leave the column NULL, but cannot protect user deletion; deploy the actor-aware code to every writer before enabling real fencing. The actor lease adds database writes and checks to serving operations, including ephemeral events. No production throughput or latency benchmark is claimed. Schema rollout and serving-path overhead need review. diff --git a/migrations/0075_serving_write_actor.sql b/migrations/0076_serving_write_actor.sql similarity index 100% rename from migrations/0075_serving_write_actor.sql rename to migrations/0076_serving_write_actor.sql From a05c3c49dceaf8549257dbb6f6e12c0899c82792 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 11:41:55 -0400 Subject: [PATCH 04/11] Limit user fencing to event database writes Signed-off-by: Ravneet Arora --- crates/buzz-db/src/runtime/admitted_tx.rs | 19 -- crates/buzz-db/src/runtime/migration.rs | 35 +-- crates/buzz-db/src/store/deletion.rs | 76 +----- .../buzz-db/src/store/personal_read/writes.rs | 1 - crates/buzz-db/src/store/relay_invite.rs | 6 +- .../src/store/relay_invite/claim_owner.rs | 12 +- .../writer_protocol_postgres_tests.rs | 10 +- crates/buzz-db/src/store/replaceable.rs | 2 - crates/buzz-db/src/store/user.rs | 2 - ...ser_deletion_actor_lease_postgres_tests.rs | 216 ----------------- .../buzz-db/src/store/user_deletion_fences.rs | 36 +-- .../src/store/user_deletion_write_guard.rs | 35 +-- ...ser_deletion_write_guard_postgres_tests.rs | 79 +----- crates/buzz-deletion/Cargo.toml | 2 +- crates/buzz-deletion/src/lib.rs | 47 +--- crates/buzz-media/src/error.rs | 14 +- crates/buzz-relay/src/api/bridge.rs | 16 +- crates/buzz-relay/src/api/buzz_v1/handlers.rs | 1 - crates/buzz-relay/src/api/gifs.rs | 13 +- crates/buzz-relay/src/api/git/settings.rs | 33 +-- crates/buzz-relay/src/api/git/transport.rs | 124 +--------- crates/buzz-relay/src/api/invites.rs | 45 +--- .../buzz-relay/src/api/invites/claim_auth.rs | 4 - crates/buzz-relay/src/api/media.rs | 40 +--- crates/buzz-relay/src/audio/handler.rs | 139 +---------- crates/buzz-relay/src/handlers/artifact.rs | 5 +- crates/buzz-relay/src/handlers/event.rs | 79 ++---- .../buzz-relay/src/handlers/identity_write.rs | 104 -------- crates/buzz-relay/src/handlers/ingest.rs | 28 +-- crates/buzz-relay/src/handlers/mod.rs | 1 - .../user_deletion_expanded_postgres_tests.rs | 117 +-------- .../user_deletion_message_postgres_tests.rs | 13 +- .../user_deletion_remaining_postgres_tests.rs | 225 ------------------ crates/buzz-workflow/src/executor.rs | 32 +-- docs/user-deletion-message-fencing.md | 72 ++---- migrations/0076_serving_write_actor.sql | 10 - .../public/community_serving_write_leases.sql | 6 - 37 files changed, 130 insertions(+), 1569 deletions(-) delete mode 100644 crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs delete mode 100644 crates/buzz-relay/src/handlers/identity_write.rs delete mode 100644 crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs delete mode 100644 migrations/0076_serving_write_actor.sql diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index 315af96d19f..45d65181e86 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -145,11 +145,6 @@ impl AdmittedTx { &mut self.tx } - pub(crate) async fn guard_identity(&mut self, actor: nostr::PublicKey) -> Result<()> { - crate::store::user_deletion_write_guard::guard_identity(&mut self.tx, self.community, actor) - .await - } - pub(crate) async fn guard_event_author(&mut self, event: &nostr::Event) -> Result<()> { crate::store::user_deletion_write_guard::guard_event_author( &mut self.tx, @@ -159,20 +154,6 @@ impl AdmittedTx { .await } - pub(crate) async fn guard_agent_owner_assignment( - &mut self, - agent: &[u8], - owner: &[u8], - ) -> Result<()> { - crate::store::user_deletion_write_guard::guard_owner_assignment( - &mut self.tx, - self.community, - agent, - owner, - ) - .await - } - /// Record that an event committed with this transaction belongs to /// `channel_id`, so [`AdmittedTx::commit`] refreshes the channel's TTL. pub(crate) fn record_channel_event(&mut self, channel_id: Option, kind: i32) { diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 6d97075ed32..7a1a551b7c0 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -776,7 +776,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 76); + assert_eq!(migrations.len(), 75); for (index, migration) in migrations.iter().enumerate() { assert_eq!(migration.version, (index + 1) as i64); } @@ -2289,42 +2289,15 @@ mod postgres_tests { assert!(!migration.fence_attachments.is_empty()); assert!(!migration.registry_rows.is_empty()); - let actor_migration = MIGRATOR - .iter() - .find(|migration| migration.version == 76) - .expect("actor serving lease migration"); - assert!(actor_migration - .sql - .as_str() - .contains("ALTER TABLE community_serving_write_leases ADD COLUMN actor_pubkey TEXT")); - let actor_surface = surface(actor_migration.sql.as_ref()); - assert_eq!(actor_surface.indexes.len(), 1); - for index in &actor_surface.indexes { - assert!( - schema.indexes.contains(index), - "actor lease index differs: {index}" - ); - } - for (table, original_definition) in &migration.tables { - // 0076 adds this nullable field; preserve parity for every other - // column in the original serving-lease table. - let definition = if table == "community_serving_write_leases" { - original_definition.replacen( - "operation text not null, ", - "operation text not null, actor_pubkey text, ", - 1, - ) - } else { - original_definition.clone() - }; + for (table, definition) in &migration.tables { let in_schema = schema .tables .get(table) .unwrap_or_else(|| panic!("schema.sql is missing deletion table {table}")); if table != "community_deletion_requests" && table != "community_deletion_approvals" { assert_eq!( - in_schema, &definition, - "schema.sql definition of {table} drifted from its migrations" + in_schema, definition, + "schema.sql definition of {table} drifted from migration 0029" ); } } diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index a6bd057f40f..b04b36de5a2 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -720,8 +720,6 @@ pub struct ServingWriteLease { pub community_id: CommunityId, /// Operation category for diagnostics. pub operation: String, - /// Authenticated actor for user-deletion exclusion, if this is a user write. - pub actor_pubkey: Option, /// Process/executor identity. pub owner: String, /// Monotonic lease generation. @@ -3036,9 +3034,6 @@ impl DeletionStore { lease: &ServingWriteLease, ) -> Result<()> { lock_community_deletion_shared(tx, lease.community_id).await?; - if let Some(actor) = lease.actor_pubkey { - super::user_deletion_write_guard::guard_identity(tx, lease.community_id, actor).await?; - } let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -3120,44 +3115,6 @@ impl DeletionStore { operation: &str, owner: &str, lease_duration: Duration, - ) -> Result { - self.acquire_serving_write_lease_for_actor( - community, - operation, - owner, - lease_duration, - None, - ) - .await - } - - /// Acquire an external-effect lease under the actor's shared identity locks. - /// A user fence cannot commit while this exact actor's lease remains live. - pub async fn acquire_actor_serving_write_lease( - &self, - community: CommunityId, - operation: &str, - owner: &str, - lease_duration: Duration, - actor: nostr::PublicKey, - ) -> Result { - self.acquire_serving_write_lease_for_actor( - community, - operation, - owner, - lease_duration, - Some(actor), - ) - .await - } - - async fn acquire_serving_write_lease_for_actor( - &self, - community: CommunityId, - operation: &str, - owner: &str, - lease_duration: Duration, - actor: Option, ) -> Result { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); self.bounded_serving_lease_sql(SERVING_WRITE_LEASE_SQL_TIMEOUT, async |connection| { @@ -3166,14 +3123,11 @@ impl DeletionStore { // The lease INSERT remains in this transaction and rechecks active // state after admission, so a fence cannot interleave. guard_community_write(&mut tx, community).await?; - if let Some(actor) = actor { - super::user_deletion_write_guard::guard_identity(&mut tx, community, actor).await?; - } let row = sqlx::query( "INSERT INTO community_serving_write_leases \ - (community_id, operation, owner, fence_generation, lease_until, actor_pubkey) \ + (community_id, operation, owner, fence_generation, lease_until) \ SELECT id, $2, $3, deletion_fence_generation, \ - now() + make_interval(secs => $4), $5 \ + now() + make_interval(secs => $4) \ FROM communities WHERE id = $1 AND deletion_state = 'active' \ AND deleted_at IS NULL \ RETURNING id, generation, fence_generation, lease_until", @@ -3182,7 +3136,6 @@ impl DeletionStore { .bind(operation) .bind(owner) .bind(lease_seconds) - .bind(actor.map(|key| key.to_hex())) .fetch_optional(&mut *tx) .await? .ok_or_else(|| { @@ -3192,7 +3145,6 @@ impl DeletionStore { id: row.try_get("id")?, community_id: community, operation: operation.to_owned(), - actor_pubkey: actor, owner: owner.to_owned(), generation: row.try_get("generation")?, fence_generation: row.try_get("fence_generation")?, @@ -3225,10 +3177,6 @@ impl DeletionStore { .await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; - if let Some(actor) = lease.actor_pubkey { - super::user_deletion_write_guard::guard_identity(&mut tx, lease.community_id, actor) - .await?; - } let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ SET lease_until = now() + make_interval(secs => $6), heartbeat_at = now() \ @@ -3290,14 +3238,6 @@ impl DeletionStore { self.bounded_serving_lease_sql(SERVING_WRITE_LEASE_SQL_TIMEOUT, async |connection| { let mut tx = serving_lease_transaction(connection).await?; lock_community_deletion_shared(&mut tx, lease.community_id).await?; - if let Some(actor) = lease.actor_pubkey { - super::user_deletion_write_guard::guard_identity( - &mut tx, - lease.community_id, - actor, - ) - .await?; - } let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -4647,17 +4587,6 @@ mod postgres_tests { crate::migration::run_migrations_through(&db.pool, 66) .await .expect("migrate with fence objects present"); - // Keep the historical universal fences for this retirement test, but - // include the additive serving-lease shape required by current writers. - let mut schema_tx = db.pool.begin().await.unwrap(); - sqlx::raw_sql(include_str!( - "../../../../migrations/0076_serving_write_actor.sql" - )) - .execute(&mut *schema_tx) - .await - .unwrap(); - schema_tx.commit().await.unwrap(); - let store = db.deletion_store(); let community = db .ensure_configured_community("optional-fences.example") @@ -7550,7 +7479,6 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); id: Uuid::new_v4(), community_id: community, operation: "isolation-probe".to_owned(), - actor_pubkey: None, owner: "isolation-probe".to_owned(), generation: 0, fence_generation: 0, diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index da6afaea752..77df951f595 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -237,7 +237,6 @@ impl Db { ) .await?; deadlines(tx.conn()).await?; - tx.guard_identity(*actor).await?; let actor = actor.to_bytes(); lock_account(&mut tx, community, &actor).await?; let outcome = apply(&mut tx, community, &actor, intent).await?; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 3e883523a33..6017ae1f22f 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -578,7 +578,7 @@ pub async fn claim_relay_invite_with_owner( .await?; } let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; tx.commit().await?; log_claim_outcome( @@ -636,7 +636,7 @@ pub async fn claim_relay_invite_with_owner( if !inserted { let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; tx.commit().await?; log_claim_outcome( @@ -665,7 +665,7 @@ pub async fn claim_relay_invite_with_owner( .await?; let owner = - claim_owner::persist_claim_owner(&mut tx, community, claimer_pubkey, verified_owner) + claim_owner::persist_claim_owner(tx.conn(), community, claimer_pubkey, verified_owner) .await?; let snapshot = write_nip43_membership_snapshot(&mut tx, relay_keypair).await?; tx.commit().await?; diff --git a/crates/buzz-db/src/store/relay_invite/claim_owner.rs b/crates/buzz-db/src/store/relay_invite/claim_owner.rs index 18a41e027a7..7e275b1263d 100644 --- a/crates/buzz-db/src/store/relay_invite/claim_owner.rs +++ b/crates/buzz-db/src/store/relay_invite/claim_owner.rs @@ -57,7 +57,7 @@ pub(super) struct OwnerPersistence { /// Called only on an admitted claim, while the admission lock is still held. pub(super) async fn persist_claim_owner( - tx: &mut crate::AdmittedTx, + tx: &mut sqlx::PgConnection, community: CommunityId, claimant: &str, verified_owner: Option<&str>, @@ -69,8 +69,6 @@ pub(super) async fn persist_claim_owner( hex::decode(owner).map_err(|_| DbError::InvalidData("invalid invitation owner".into()))?; let claimant_bytes = hex::decode(claimant) .map_err(|_| DbError::InvalidData("invalid invitation principal".into()))?; - tx.guard_agent_owner_assignment(&claimant_bytes, &owner_bytes) - .await?; let mut users_created = 0; for key in [&owner_bytes, &claimant_bytes] { users_created += sqlx::query( @@ -78,17 +76,17 @@ pub(super) async fn persist_claim_owner( ) .bind(community.as_uuid()) .bind(key) - .execute(tx.conn()) + .execute(&mut *tx) .await? .rows_affected(); } let link_created = sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2 AND agent_owner_pubkey IS NULL") - .bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(tx.conn()).await?.rows_affected() > 0; + .bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut *tx).await?.rows_affected() > 0; // Defense in depth: `claimant_restricted` already refused a banned owner // under the same admission lock, so this finds nothing today. It keeps // parity with every other owner writer if that check ever moves. if link_created { - super::revoke_restricted_agent_invites(tx.conn(), community, &claimant_bytes, &owner_bytes) + super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes) .await?; } Ok(OwnerPersistence { @@ -142,7 +140,7 @@ pub async fn claim_relay_membership_with_owner( .await?; } - let owner = persist_claim_owner(&mut tx, community, pubkey, verified_owner).await?; + let owner = persist_claim_owner(tx.conn(), community, pubkey, verified_owner).await?; let snapshot = if inserted { Some(super::write_nip43_membership_snapshot(&mut tx, relay_keypair).await?) } else { diff --git a/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs index e700f43c416..7157267ec92 100644 --- a/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/writer_protocol_postgres_tests.rs @@ -13,14 +13,8 @@ async fn authorization_owner_writer_waits_before_claim_owner_row_and_links_once( .await .unwrap(); } - let mut claim = crate::begin_community_write_transaction( - &pool, - community, - crate::observability::WriterOperation::Authorization, - ) - .await - .unwrap(); - lock_admission(claim.conn(), community).await.unwrap(); + let mut claim = pool.begin().await.unwrap(); + lock_admission(&mut claim, community).await.unwrap(); let (writer_pool, name) = named_pool().await; let setter = tokio::spawn(async move { Db::from_pool(writer_pool) diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 1073efad92b..268151e967a 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -109,7 +109,6 @@ async fn replace_parameterized_event_in_transaction_impl( channel_id: Option, precondition: ParameterizedReplacePrecondition<'_>, ) -> Result { - tx.guard_event_author(event).await?; let community_id = tx.community(); let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); @@ -432,7 +431,6 @@ impl Db { ) .await?; tx.set_push_enqueue(self.push_enqueue.clone()); - tx.guard_event_author(event).await?; let transaction_timer = observability::TransactionTimer::start( observability::TransactionOperation::ReplaceAddressableEvent, ); diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 6a648356ce7..19f80799d77 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -376,8 +376,6 @@ async fn set_agent_owner_with_operation( drop(connection); let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; crate::relay_invite::lock_admission(tx.conn(), community_id).await?; - tx.guard_agent_owner_assignment(agent_pubkey, owner_pubkey) - .await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( diff --git a/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs deleted file mode 100644 index fc1cd0f89ad..00000000000 --- a/crates/buzz-db/src/store/user_deletion_actor_lease_postgres_tests.rs +++ /dev/null @@ -1,216 +0,0 @@ -use super::*; - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn actor_leases_exclude_fencing_until_release_and_do_not_block_other_users() { - let f = Fixture::new().await; - let user = Keys::generate(); - let other = Keys::generate(); - let store = f.db.deletion_store(); - let mut write = store - .acquire_actor_serving_write_lease( - f.community, - "upload-test", - "writer", - Duration::from_secs(60), - user.public_key(), - ) - .await - .unwrap(); - let mut gate = f.pool.begin().await.unwrap(); - assert!( - matches!(fences::insert(&mut gate, FenceIdentity::user(user.public_key()), &f.lease, None).await, - Err(DbError::AccessDenied(reason)) if reason.contains("still in flight")) - ); - gate.rollback().await.unwrap(); - store - .renew_serving_write_lease(&mut write, Duration::from_secs(60)) - .await - .unwrap(); - store.verify_serving_write_lease(&write).await.unwrap(); - assert!(store.release_serving_write_lease(&write).await.unwrap()); - f.fence(&user, None).await; - assert!(matches!( - store - .acquire_actor_serving_write_lease( - f.community, - "upload-test", - "writer", - Duration::from_secs(60), - user.public_key(), - ) - .await, - Err(DbError::UserDeletionRestricted) - )); - let control = store - .acquire_actor_serving_write_lease( - f.community, - "upload-test", - "writer", - Duration::from_secs(60), - other.public_key(), - ) - .await - .unwrap(); - store.release_serving_write_lease(&control).await.unwrap(); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn expired_actor_lease_cannot_renew_or_finalize_after_fencing() { - let f = Fixture::new().await; - let user = Keys::generate(); - let store = f.db.deletion_store(); - let mut write = store - .acquire_actor_serving_write_lease( - f.community, - "git-test", - "writer", - Duration::from_secs(60), - user.public_key(), - ) - .await - .unwrap(); - sqlx::query("UPDATE community_serving_write_leases SET lease_until=clock_timestamp()-interval '1 second' WHERE id=$1") - .bind(write.id).execute(&f.pool).await.unwrap(); - f.fence(&user, None).await; - assert!(store - .renew_serving_write_lease(&mut write, Duration::from_secs(60)) - .await - .is_err()); - assert!(store.verify_serving_write_lease(&write).await.is_err()); - let mut tx = f.pool.begin().await.unwrap(); - assert!(store - .guard_transaction_with_serving_lease(&mut tx, &write) - .await - .is_err()); - tx.rollback().await.unwrap(); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn scoped_agent_lease_and_fence_leave_another_community_writable() { - let f = Fixture::new().await; - let owner = Keys::generate(); - let agent = f.agent(&owner).await; - let other = - f.db.ensure_configured_community(&format!("actor-lease-{}.test", Uuid::new_v4())) - .await - .unwrap() - .id; - let store = f.db.deletion_store(); - let write = store - .acquire_actor_serving_write_lease( - other, - "upload-test", - "writer", - Duration::from_secs(60), - agent.public_key(), - ) - .await - .unwrap(); - f.fence(&agent, Some(&owner)).await; - store.verify_serving_write_lease(&write).await.unwrap(); - assert!(matches!( - store - .acquire_actor_serving_write_lease( - f.community, - "upload-test", - "writer", - Duration::from_secs(60), - agent.public_key(), - ) - .await, - Err(DbError::UserDeletionRestricted) - )); - store.release_serving_write_lease(&write).await.unwrap(); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn invite_claims_cannot_create_agents_for_a_fenced_owner() { - let f = Fixture::new().await; - let owner = Keys::generate(); - let relay = Keys::generate(); - let invite = crate::relay_invite::mint_relay_invite( - &f.pool, - f.community, - &relay.public_key().to_hex(), - 3600, - Some(10), - ) - .await - .unwrap(); - let hash = buzz_core::invite::hash_v2_code(&invite.code); - for fenced in [false, true] { - if fenced { - f.fence(&owner, None).await; - } - for legacy in [false, true] { - let agent = Keys::generate(); - let result = if legacy { - crate::relay_invite::claim_relay_membership_with_owner( - &f.pool, - f.community, - &agent.public_key().to_hex(), - "member", - None, - Some(&owner.public_key().to_hex()), - &relay, - ) - .await - .map(|write| write.outcome.owner_link_created) - } else { - crate::relay_invite::claim_relay_invite_with_owner( - &f.pool, - f.community, - &hash, - &agent.public_key().to_hex(), - None, - Some(&owner.public_key().to_hex()), - &relay, - ) - .await - .map(|write| write.outcome.owner_link_created) - }; - if fenced { - assert!( - matches!(result, Err(DbError::UserDeletionRestricted)), - "{result:?}" - ); - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM users WHERE community_id=$1 AND pubkey=$2", - ) - .bind(f.community.as_uuid()) - .bind(agent.public_key().as_bytes()) - .fetch_one(&f.pool) - .await - .unwrap(); - assert_eq!(count, 0, "refused claim must not create the agent"); - } else { - assert!(result.unwrap(), "baseline must actually link ownership"); - } - assert_eq!( - crate::relay_members::is_relay_member( - &f.pool, - f.community, - &agent.public_key().to_hex(), - ) - .await - .unwrap(), - !fenced, - ); - } - } - let used: i32 = - sqlx::query_scalar("SELECT use_count FROM relay_invites WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(invite.invite_id) - .fetch_one(&f.pool) - .await - .unwrap(); - assert_eq!( - used, 1, - "refused claim must not consume another invitation use" - ); -} diff --git a/crates/buzz-db/src/store/user_deletion_fences.rs b/crates/buzz-db/src/store/user_deletion_fences.rs index c4cf8a8807f..dcf221baf3a 100644 --- a/crates/buzz-db/src/store/user_deletion_fences.rs +++ b/crates/buzz-db/src/store/user_deletion_fences.rs @@ -1,6 +1,5 @@ -//! Operator-global restriction storage and identity locks. Shared persistent-event -//! insertion paths and agent-owner assignment consult these through the write guard. -//! Actor leases also protect external effects; the real fencing worker is not integrated yet. +//! Operator-global restriction storage and identity locks. Shared event insertion +//! paths consult these restrictions; the real user-deletion worker is not integrated. //! //! Callers use a writer-pool READ COMMITTED transaction. Acquire existing //! community guards first, then `lock_request` for the worker, then all human @@ -151,20 +150,20 @@ async fn lock( /// For multiple identities, acquire the full shared set first. The caller must /// refuse a restricted operation and retain this transaction through any write. /// An agent lookup is scoped; it does not resolve or check the human owner. -/// The message-write path locks the verified owner and agent together, then -/// reads the signer's restrictions, not the owner's. This requires the gate to install the +/// The planned agent-write path locks the verified owner and agent together, +/// then reads the agent restriction only. This requires the gate to install the /// human and all owned-agent restrictions atomically, and creation/assignment -/// paths to reject restricted owners. The real gate is not wired in yet. +/// paths to reject restricted owners. These callers are not wired in yet. pub async fn find( tx: &mut Transaction<'_, Postgres>, identity: FenceIdentity, ) -> Result> { lock_shared(tx, &[identity]).await?; - read_locked(tx, identity).await + read(tx, identity).await } -/// Read one exact restriction after the caller has locked its identity. -pub(super) async fn read_locked( +/// Read one restriction after the caller has acquired its identity lock. +pub(super) async fn read( tx: &mut Transaction<'_, Postgres>, identity: FenceIdentity, ) -> Result> { @@ -221,23 +220,6 @@ pub async fn insert( lock_request(tx, lease).await?; let request_id = lease.request_id; lock_exclusive(tx, &[identity]).await?; - // Actor leases are registered under these same shared identity locks. - // Refuse completion while admitted external or multi-transaction work runs; - // the executor retries after it finishes, rather than holding locks to poll. - let active: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases - WHERE actor_pubkey = $1 AND ($2::uuid IS NULL OR community_id = $2) - AND lease_until >= clock_timestamp())", - ) - .bind(identity.pubkey.to_hex()) - .bind(identity.scope_id()) - .fetch_one(&mut **tx) - .await?; - if active { - return Err(DbError::AccessDenied( - "identity writes are still in flight; retry fencing".into(), - )); - } let owner = source_owner.map(|key| key.to_hex()); sqlx::query( "INSERT INTO user_deletion_fences (pubkey, scope_community_id, request_id, source_owner_pubkey) @@ -245,7 +227,7 @@ pub async fn insert( ) .bind(identity.pubkey.to_hex()).bind(identity.scope_id()).bind(request_id).bind(&owner) .execute(&mut **tx).await?; - let saved = read_locked(tx, identity) + let saved = read(tx, identity) .await? .ok_or_else(|| DbError::DeletionSafety("fence insertion returned no row".into()))?; if saved.request_id != request_id || saved.source_owner_pubkey != owner { diff --git a/crates/buzz-db/src/store/user_deletion_write_guard.rs b/crates/buzz-db/src/store/user_deletion_write_guard.rs index 36c804461b8..b3cbf549629 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard.rs @@ -1,4 +1,4 @@ -//! Guard shared persistent-event insertion paths and agent ownership. +//! Guard shared persistent-event insertion paths. //! Other writes are not fenced here; real deletion must remain disabled until //! every serving path and external-work drain has been integrated. @@ -6,7 +6,7 @@ use buzz_core::CommunityId; use nostr::{Event, PublicKey}; use sqlx::{Postgres, Transaction}; -use super::user_deletion_fences::{lock_exclusive, lock_shared, read_locked, FenceIdentity}; +use super::user_deletion_fences::{lock_shared, read as read_locked, FenceIdentity}; use crate::{DbError, Result}; pub(crate) async fn guard_event_author( @@ -14,14 +14,7 @@ pub(crate) async fn guard_event_author( community: CommunityId, event: &Event, ) -> Result<()> { - guard_identity(tx, community, event.pubkey).await -} - -pub(crate) async fn guard_identity( - tx: &mut Transaction<'_, Postgres>, - community: CommunityId, - signer: PublicKey, -) -> Result<()> { + let signer = event.pubkey; let owner = owner(tx, community, signer).await?; let mut identities = vec![ FenceIdentity::user(signer), @@ -30,7 +23,6 @@ pub(crate) async fn guard_identity( if let Some(owner) = owner { identities.push(FenceIdentity::user(owner)); } - // Global signer lock also coordinates the first assignment of an owner. // Never derive the owner from a cached session or an event tag. lock_shared(tx, &identities).await?; if owner != self::owner(tx, community, signer).await? { @@ -43,27 +35,6 @@ pub(crate) async fn guard_identity( check_signer(tx, community, signer).await } -pub(crate) async fn guard_owner_assignment( - tx: &mut Transaction<'_, Postgres>, - community: CommunityId, - agent: &[u8], - owner: &[u8], -) -> Result<()> { - let agent = parse_key(agent)?; - let owner = parse_key(owner)?; - lock_shared( - tx, - &[FenceIdentity::user(agent), FenceIdentity::user(owner)], - ) - .await?; - // Ownership must not change while a message holds the scoped shared lock. - lock_exclusive(tx, &[FenceIdentity::agent(community, agent)]).await?; - if read_locked(tx, FenceIdentity::user(owner)).await?.is_some() { - return Err(DbError::UserDeletionRestricted); - } - check_signer(tx, community, agent).await -} - async fn owner( tx: &mut Transaction<'_, Postgres>, community: CommunityId, diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs index 7d6f39361cf..e1cc52ed252 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -166,6 +166,10 @@ async fn message_writers_enforce_global_and_scoped_fences_even_without_profiles( .unwrap(); } f.fence(&user, None).await; + // The stored owner's restriction is not the agent's restriction. + f.db.insert_event(f.community, &message(&agent, 9), None) + .await + .unwrap(); f.fence(&agent, Some(&user)).await; for kind in [ 9, 40002, 45001, 45003, 7, 40003, 45002, 9007, 9000, 9002, 40100, 5, 45010, 1234, @@ -363,80 +367,5 @@ async fn ownership_change_while_waiting_rejects_and_retry_uses_current_owner() { ); } -#[tokio::test] -#[ignore = "requires Postgres"] -async fn first_owner_assignment_waits_for_messages_and_rejects_restricted_owners() { - let f = Fixture::new().await; - let agent = Keys::generate(); - let owner = Keys::generate(); - f.db.ensure_user(f.community, owner.public_key().as_bytes()) - .await - .unwrap(); - f.db.ensure_user(f.community, agent.public_key().as_bytes()) - .await - .unwrap(); - let mut tx = crate::begin_community_event_write_transaction( - &f.pool, - f.community, - crate::observability::WriterOperation::EventWrite, - ) - .await - .unwrap(); - event::insert_event_in_transaction(&mut tx, &message(&agent, 9), None) - .await - .unwrap(); - // A dedicated single-connection pool exposes the actual setter's backend. - let assign_pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(1) - .connect(&crate::test_support::database_url()) - .await - .unwrap(); - let pid = sqlx::query_scalar("SELECT pg_backend_pid()") - .fetch_one(&assign_pool) - .await - .unwrap(); - let assignment = tokio::spawn({ - let (agent, owner, community) = (agent.public_key(), owner.public_key(), f.community); - async move { - super::user::set_agent_owner( - &assign_pool, - community, - agent.as_bytes(), - owner.as_bytes(), - ) - .await - } - }); - wait_for_waiter(&f.pool, pid).await; - tx.commit().await.unwrap(); - assert!(assignment.await.unwrap().unwrap()); - f.fence(&owner, None).await; - let another = Keys::generate(); - f.db.ensure_user(f.community, another.public_key().as_bytes()) - .await - .unwrap(); - assert!(matches!( - f.db.set_agent_owner( - f.community, - another.public_key().as_bytes(), - owner.public_key().as_bytes() - ) - .await, - Err(DbError::UserDeletionRestricted) - )); - let saved: Option> = sqlx::query_scalar( - "SELECT agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2", - ) - .bind(f.community.as_uuid()) - .bind(another.public_key().to_bytes().as_slice()) - .fetch_one(&f.pool) - .await - .unwrap(); - assert!(saved.is_none()); -} - #[path = "user_deletion_lock_order_postgres_tests.rs"] mod lock_order_postgres_tests; - -#[path = "user_deletion_actor_lease_postgres_tests.rs"] -mod actor_lease_postgres_tests; diff --git a/crates/buzz-deletion/Cargo.toml b/crates/buzz-deletion/Cargo.toml index a087e09786f..6937974c844 100644 --- a/crates/buzz-deletion/Cargo.toml +++ b/crates/buzz-deletion/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true description = "Durable whole-community deletion engine for Buzz" [dependencies] -nostr = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } buzz-core = { workspace = true } @@ -26,4 +25,5 @@ tokio-util = { workspace = true } uuid = { workspace = true } [dev-dependencies] +nostr = { workspace = true } sqlx = { workspace = true } diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 4ef22dcdb9c..c9e78423942 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -185,59 +185,20 @@ pub async fn acquire_serving_write( community: buzz_core::CommunityId, operation: &str, ) -> Result { - acquire_serving_write_for_actor(db, community, operation, heartbeat_interval(), None).await + acquire_serving_write_with_heartbeat(db, community, operation, heartbeat_interval()).await } -/// Acquire the existing durable serving lease with authenticated actor fencing. -/// No pool connection is held during the external operation. A live actor lease -/// prevents that actor's fence from committing until the operation releases it. -pub async fn acquire_actor_serving_write( - db: &Db, - community: buzz_core::CommunityId, - operation: &str, - actor: nostr::PublicKey, -) -> Result { - acquire_serving_write_for_actor(db, community, operation, heartbeat_interval(), Some(actor)) - .await -} - -#[cfg(test)] async fn acquire_serving_write_with_heartbeat( db: &Db, community: buzz_core::CommunityId, operation: &str, heartbeat_period: Duration, -) -> Result { - acquire_serving_write_for_actor(db, community, operation, heartbeat_period, None).await -} - -async fn acquire_serving_write_for_actor( - db: &Db, - community: buzz_core::CommunityId, - operation: &str, - heartbeat_period: Duration, - actor: Option, ) -> Result { let store = store(db); let owner = default_executor_id(); - let lease = match actor { - Some(actor) => { - store - .acquire_actor_serving_write_lease( - community, - operation, - &owner, - DEFAULT_LEASE_DURATION, - actor, - ) - .await? - } - None => { - store - .acquire_serving_write_lease(community, operation, &owner, DEFAULT_LEASE_DURATION) - .await? - } - }; + let lease = store + .acquire_serving_write_lease(community, operation, &owner, DEFAULT_LEASE_DURATION) + .await?; let heartbeat_store = store.clone(); let mut heartbeat_lease = lease.clone(); let cancel = CancellationToken::new(); diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index 1c3d0130f6b..9a72c7ff3a4 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -82,8 +82,6 @@ pub enum MediaError { RelayMembershipRequired, #[error("community writes are fenced")] CommunityWriteFenced, - #[error("identity is restricted for user deletion")] - IdentityWriteFenced, #[error("media service temporarily unavailable")] ServiceUnavailable, /// An access-policy lookup failed. Legacy responses match @@ -173,9 +171,9 @@ impl MediaError { | Self::MissingTag(_) => Some(BlossomDenialKind::EvidenceRejected), // Local-policy denials: identity proved, but access refused by relay policy. // Maps to AuthorizationDenied per NIP-FI.md:764-775. - Self::RelayMembershipRequired - | Self::CommunityWriteFenced - | Self::IdentityWriteFenced => Some(BlossomDenialKind::AuthorizationDenied), + Self::RelayMembershipRequired | Self::CommunityWriteFenced => { + Some(BlossomDenialKind::AuthorizationDenied) + } Self::AuthorizationUnavailable => Some(BlossomDenialKind::AuthorizationUnavailable), _ => None, } @@ -224,9 +222,9 @@ impl IntoResponse for MediaError { ) } Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()), - Self::RelayMembershipRequired - | Self::CommunityWriteFenced - | Self::IdentityWriteFenced => (StatusCode::FORBIDDEN, self.to_string()), + Self::RelayMembershipRequired | Self::CommunityWriteFenced => { + (StatusCode::FORBIDDEN, self.to_string()) + } Self::ServiceUnavailable | Self::AuthorizationUnavailable => { (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index f04f44556bc..efbdc4f7692 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1230,9 +1230,6 @@ async fn submit_event_authed( ) .await }; - // A delegated member must establish its verified owner before any write. - // Open-relay backfill remains optional; direct members need no owner link. - let owner_link_required = matches!(&membership, Ok(Some(_))); let nip_oa_owner = match membership { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { @@ -1253,18 +1250,7 @@ async fn submit_event_authed( } }; if let Some(owner) = nip_oa_owner { - let linked = - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; - if owner_link_required && !linked { - return SubmitOutcome::Err { - status: StatusCode::SERVICE_UNAVAILABLE, - response: api_error( - StatusCode::SERVICE_UNAVAILABLE, - crate::handlers::auth::OWNER_LINK_ERROR, - ) - .into(), - }; - } + super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } let auth = IngestAuth::Http { diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index cfdabf72bab..f3dbf095654 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -205,7 +205,6 @@ pub(super) async fn write_intent( .await { Ok(outcome) => json!(outcome), - Err(buzz_db::DbError::UserDeletionRestricted) => json!({"status":"blocked"}), Err(_) => json!({"status":"unknown","retryable":true}), } } diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index cb50b98cfc0..c1d087dfce0 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -308,19 +308,8 @@ async fn share_inner( ) -> Result { // Admission first: NIP-98 + NIP-FI must fire before provider availability // check. [FI-TRACE-HTTP-INGRESS] - let (tenant, actor) = - bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; - crate::handlers::identity_write::run_http(&state, &tenant, actor, "gif_share", async { - share_authenticated(&state, body).await - }) - .await -} + bridge::authenticate_member_post(&state, &headers, SHARE_PATH, &body).await?; -#[allow(clippy::result_large_err)] -async fn share_authenticated( - state: &AppState, - body: axum::body::Bytes, -) -> Result { let Some(config) = state.config.klipy.as_ref() else { return Err( api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index b21fb7e8254..f11e11026b1 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -209,7 +209,7 @@ async fn authenticate( .await .map_err(IntoResponse::into_response)?; let tag = relay_members::extract_auth_tag_header(headers); - let delegated_owner = relay_members::enforce_relay_membership( + relay_members::enforce_relay_membership( state, &tenant, pubkey.as_bytes(), @@ -219,14 +219,6 @@ async fn authenticate( ) .await .map_err(IntoResponse::into_response)?; - if let Some(owner) = delegated_owner { - if !relay_members::materialize_nip_oa_owner(state, &tenant, &pubkey, &owner).await { - return Err(error( - StatusCode::SERVICE_UNAVAILABLE, - crate::handlers::auth::OWNER_LINK_ERROR, - )); - } - } #[cfg(test)] crate::nip_fi_test_hooks::before_http_ban_recheck(tenant.community()).await; deny_banned_git_principal( @@ -384,28 +376,17 @@ async fn set_default_branch( })?; let snapshot = DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?; - let serving_write = buzz_deletion::acquire_actor_serving_write( + let serving_write = buzz_deletion::acquire_serving_write( &state.db, auth.tenant.community(), "git_default_branch", - auth.caller, ) .await - .map_err(|cause| { - if matches!( - cause.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ) { - error( - StatusCode::FORBIDDEN, - "identity is restricted for user deletion", - ) - } else { - error( - StatusCode::SERVICE_UNAVAILABLE, - "community writes are fenced", - ) - } + .map_err(|_| { + error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are fenced", + ) })?; serving_write.verify().await.map_err(backend)?; let (snapshot, changed) = serving_write diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 8f61180ccda..0df9101bc0a 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -248,22 +248,6 @@ impl axum::extract::FromRequestParts> for GitAuth { (StatusCode::FORBIDDEN, "restricted: not a relay member").into_response(), )); } - Ok(crate::api::relay_members::MembershipDecision::ViaOwner(owner)) => { - if token_owner.is_none() - && !crate::api::relay_members::materialize_nip_oa_owner( - state, &tenant, &pubkey, &owner, - ) - .await - { - return Err(unavailable( - ( - StatusCode::SERVICE_UNAVAILABLE, - crate::handlers::auth::OWNER_LINK_ERROR, - ) - .into_response(), - )); - } - } Ok(_) => {} Err(e) => { warn!(pubkey = %pubkey.to_hex(), error = %e, "git: relay membership lookup failed"); @@ -2031,26 +2015,15 @@ async fn finalize_push_inner( // An already-running receive-pack may cross the durable fence after // request admission. Revalidate immediately before object-store CAS; DB // trigger fencing alone cannot roll back an S3 pointer mutation. - let serving_write = match buzz_deletion::acquire_actor_serving_write( + let serving_write = match buzz_deletion::acquire_serving_write( &state.db, ctx.tenant.community(), "git_publish", - ctx.pusher, ) .await { Ok(guard) => guard, Err(error) => { - if matches!( - error.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ) { - return ( - StatusCode::FORBIDDEN, - "identity is restricted for user deletion", - ) - .into_response(); - } warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence"); return ( StatusCode::SERVICE_UNAVAILABLE, @@ -2848,101 +2821,6 @@ mod track_c_tests { } mod external_infra_minio_tests { - use super::*; - use uuid::Uuid; - - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] - async fn user_fence_blocks_git_publication_without_object_pointer_or_events() { - use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; - let (state, pool) = finalize_test_state().await; - let host = format!("user-git-{}.test", Uuid::new_v4()); - let community = state - .db - .ensure_configured_community(&host) - .await - .unwrap() - .id; - let user = Keys::generate(); - let peer = Keys::generate(); - for phase in 0..2 { - if phase == 1 { - let request = state - .db - .user_deletion_store() - .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) - .await - .unwrap(); - let store = state.db.entity_deletion_store(); - store - .approve( - request.id, - "reviewer", - request.inventory_digest.as_deref().unwrap(), - ) - .await - .unwrap(); - let lease = store - .claim(request.id, "worker", std::time::Duration::from_secs(60)) - .await - .unwrap() - .unwrap(); - let mut tx = pool.begin().await.unwrap(); - fences::insert( - &mut tx, - FenceIdentity::user(user.public_key()), - &lease, - None, - ) - .await - .unwrap(); - tx.commit().await.unwrap(); - } - for (index, key) in [&user, &peer].iter().enumerate() { - let repo = format!("repo-{}", Uuid::new_v4()); - let owner = key.public_key().to_hex(); - let scratch = TempDir::new().unwrap(); - let ctx = pushed_context( - &state, - community, - &host, - owner.clone(), - repo.clone(), - key.public_key(), - scratch.path(), - ) - .await; - let response = finalize_push(&state, ctx).await; - let allowed = phase == 0 || index == 1; - assert_eq!( - response.status(), - if allowed { - StatusCode::OK - } else { - StatusCode::FORBIDDEN - } - ); - let pointer = crate::api::git::manifest::pointer_key(community, &owner, &repo); - assert_eq!( - state - .git_store - .get_pointer(&pointer) - .await - .unwrap() - .is_some(), - allowed - ); - let mut query = buzz_db::event::EventQuery::for_community(community); - query.kinds = Some(vec![30618]); - query.d_tag = Some(repo); - assert_eq!( - state.db.query_events(&query).await.unwrap().len(), - usize::from(allowed) - ); - } - } - } - #[tokio::test] #[ignore = "requires Postgres and MinIO"] async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 25ebe766510..e184a35c41b 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -337,13 +337,7 @@ async fn mint_invite_checked( } } - match crate::handlers::identity_write::run_http(&state, &tenant, pubkey, "mint_invite", async { - mint_invite_inner(&state, body, tenant.clone(), pubkey) - .await - .map_err(|error| error.into_response()) - }) - .await - { + match mint_invite_inner(&state, body, tenant, pubkey).await { Ok(json) => json.into_response(), Err(e) => e.into_response(), } @@ -429,30 +423,11 @@ pub async fn claim_invite( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> axum::response::Response { - use axum::response::IntoResponse; +) -> Result, (StatusCode, Json)> { let (tenant, pubkey, verified_owner) = - match authenticate(&state, &headers, "/api/invites/claim", &body).await { - Ok(identity) => identity, - Err(error) => return error.into_response(), - }; - crate::handlers::identity_write::run_http(&state, &tenant, pubkey, "claim_invite", async { - claim_invite_inner(&state, &tenant, pubkey, verified_owner, body) - .await - .map_err(|error| error.into_response()) - }) - .await - .into_response() -} + authenticate(&state, &headers, "/api/invites/claim", &body).await?; -async fn claim_invite_inner( - state: &Arc, - tenant: &buzz_core::TenantContext, - pubkey: nostr::PublicKey, - verified_owner: Option, - body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - if claim_rate_limited(state, tenant.community(), &pubkey) { + if claim_rate_limited(&state, tenant.community(), &pubkey) { return Err(api_error( StatusCode::TOO_MANY_REQUESTS, "too many invite claim attempts, slow down", @@ -505,9 +480,9 @@ async fn claim_invite_inner( metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) .increment(write.outcome.users_created); if write.outcome.owner_link_created { - state.disconnect_unowned_agent_clusterwide(tenant, pubkey.as_bytes()); + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); } - fan_out_nip43_membership_list(tenant, state, write.snapshot).await; + fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; return match write.outcome.outcome { buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { tracing::info!( @@ -516,7 +491,7 @@ async fn claim_invite_inner( "relay member added via v2 invite" ); // NIP-43 side effects only on Joined, never on other outcomes. - if let Err(e) = publish_nip43_member_added(tenant, state, &claimer_hex).await { + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { tracing::warn!( "failed to publish NIP-43 member-added delta after v2 claim: {e}" ); @@ -600,10 +575,10 @@ async fn claim_invite_inner( metrics::counter!("buzz_users_created_total", "community" => tenant.host().to_owned()) .increment(write.outcome.users_created); if write.outcome.owner_link_created { - state.disconnect_unowned_agent_clusterwide(tenant, pubkey.as_bytes()); + state.disconnect_unowned_agent_clusterwide(&tenant, pubkey.as_bytes()); } let was_inserted = write.outcome.inserted; - fan_out_nip43_membership_list(tenant, state, write.snapshot).await; + fan_out_nip43_membership_list(&tenant, &state, write.snapshot).await; if was_inserted { tracing::info!( @@ -611,7 +586,7 @@ async fn claim_invite_inner( member = %claimer_hex, "relay member added via invite" ); - if let Err(e) = publish_nip43_member_added(tenant, state, &claimer_hex).await { + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); } } diff --git a/crates/buzz-relay/src/api/invites/claim_auth.rs b/crates/buzz-relay/src/api/invites/claim_auth.rs index 6f35b53a593..756b2ba848a 100644 --- a/crates/buzz-relay/src/api/invites/claim_auth.rs +++ b/crates/buzz-relay/src/api/invites/claim_auth.rs @@ -49,10 +49,6 @@ pub(super) async fn authenticate( pub(super) fn map_claim_error(error: buzz_db::DbError) -> (StatusCode, Json) { match error { - buzz_db::DbError::UserDeletionRestricted => api_error( - StatusCode::FORBIDDEN, - "restricted: identity is restricted for user deletion", - ), buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), buzz_db::DbError::InviteOwnerMismatch => { api_error(StatusCode::FORBIDDEN, "invite_owner_mismatch") diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index a9402b55a02..2592574b9b4 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -286,12 +286,7 @@ async fn upload_attribution( } fn serving_write_error(error: anyhow::Error) -> MediaError { - if matches!( - error.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ) { - MediaError::IdentityWriteFenced - } else if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { + if buzz_deletion::ServingWriteGuard::acquisition_is_fenced(&error) { MediaError::CommunityWriteFenced } else { MediaError::ServiceUnavailable @@ -389,7 +384,7 @@ pub(crate) async fn upload_blob( // Post-admission: relay membership gate (NIP-43). let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); - let delegated_owner = match crate::api::relay_members::enforce_relay_membership( + if let Err(e) = crate::api::relay_members::enforce_relay_membership( &state, &ctx.tenant, auth_event.pubkey.as_bytes(), @@ -398,21 +393,10 @@ pub(crate) async fn upload_blob( token_owner, ) .await + .map(|_| ()) + .map_err(|e| membership_denial(e, strictness)) { - Ok(owner) => owner, - Err(error) => return membership_denial(error, strictness).into_response(), - }; - if let Some(owner) = delegated_owner { - if !crate::api::relay_members::materialize_nip_oa_owner( - &state, - &ctx.tenant, - &auth_event.pubkey, - &owner, - ) - .await - { - return media_denial(MediaError::AuthorizationUnavailable, strictness).into_response(); - } + return e.into_response(); } // Post-admission: rate limit and concurrency permit. @@ -465,15 +449,11 @@ async fn upload_blob_result( let strictness = auth.strictness; let attribution = upload_attribution(&state, &auth, &headers).await; - let serving_write = buzz_deletion::acquire_actor_serving_write( - &state.db, - auth.tenant.community(), - "media_upload", - auth.auth_event.pubkey, - ) - .await - .map_err(serving_write_error) - .map_err(|e| media_denial(e, strictness))?; + let serving_write = + buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") + .await + .map_err(serving_write_error) + .map_err(|e| media_denial(e, strictness))?; if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 59263c264ec..21a54120693 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -946,51 +946,6 @@ async fn run_active_audio_connection( return; } }; - // Audio is a long-lived write, including binary frames that never reach - // event ingestion. Keep one actor lease for the session, not one per packet. - // The deletion worker must disconnect/drain the session before fencing can - // complete. Losing this proof cancels the existing admission and I/O loops. - let _actor_write = match buzz_deletion::acquire_actor_serving_write( - &state.db, - tenant.community(), - "huddle_audio", - pubkey, - ) - .await - { - Ok(lease) => lease, - Err(error) => { - let restricted = matches!( - error.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ); - exit_authorization_refusal( - &mut ws_send, - &control, - &mut terminal_ctrl_rx, - nip_fi_assertion.is_some(), - if restricted { - buzz_auth::DenialClass::AuthorizationDenied - } else { - buzz_auth::DenialClass::AuthorizationUnavailable - }, - serde_json::json!({"type":"error", "message": if restricted { - "restricted: identity is restricted for user deletion" - } else { "identity write lease unavailable" }}), - ) - .await; - return; - } - }; - let actor_lost = _actor_write.lost(); - let actor_cancel = cancel.clone(); - let _actor_watcher = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move { - tokio::select! { - _ = actor_lost.cancelled() => actor_cancel.cancel(), - _ = actor_cancel.cancelled() => {}, - } - })); - // Derive parent_id_for_event from the membership admission result. // This is the channel ID that lifecycle events (48101/48102/48103) belong to. let parent_id_for_event = match &membership_admission { @@ -13362,7 +13317,7 @@ mod tests { let mut blocker = pool.begin().await.expect("blocker tx"); let locked: Vec = sqlx::query_scalar( "SELECT id FROM community_serving_write_leases \ - WHERE community_id = $1 AND actor_pubkey IS NULL FOR UPDATE", + WHERE community_id = $1 FOR UPDATE", ) .bind(community.as_uuid()) .fetch_all(&mut *blocker) @@ -15445,98 +15400,6 @@ mod tests { ); } - #[tokio::test] - #[ignore = "requires Postgres and Redis"] - async fn user_deletion_fence_waits_for_audio_disconnect_then_refuses_rejoin() { - use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; - let state = audio_test_state_real_db().await.unwrap(); - let (tenant, channel, key) = seed_audio_fixture(state.db.pool()).await; - let (mut client, server) = - open_admitted_audio_socket(&state, tenant.clone(), channel, &key, None).await; - let request = state - .db - .user_deletion_store() - .submit(&format!("demo:{}", uuid::Uuid::new_v4()), "operator", None) - .await - .unwrap(); - let store = state.db.entity_deletion_store(); - store - .approve( - request.id, - "reviewer", - request.inventory_digest.as_deref().unwrap(), - ) - .await - .unwrap(); - let lease = store - .claim(request.id, "worker", std::time::Duration::from_secs(60)) - .await - .unwrap() - .unwrap(); - let identity = FenceIdentity::user(key.public_key()); - let mut tx = state.db.pool().begin().await.unwrap(); - let error = fences::insert(&mut tx, identity, &lease, None) - .await - .unwrap_err(); - assert!(error.to_string().contains("still in flight"), "{error}"); - tx.rollback().await.unwrap(); - client.close(None).await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM community_serving_write_leases WHERE actor_pubkey=$1", - ) - .bind(key.public_key().to_hex()) - .fetch_one(state.db.pool()) - .await - .unwrap(); - if count == 0 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - }) - .await - .unwrap(); - // The last participant leaving auto-archives the old huddle. - // Use a fresh open channel so membership/archive refusal cannot - // conceal a missing user-deletion guard on the next join. - let next_channel = uuid::Uuid::new_v4(); - sqlx::query( - "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ - VALUES ($1, $2, 'next-huddle', 'stream', 'open', $3)", - ) - .bind(next_channel) - .bind(tenant.community().as_uuid()) - .bind(key.public_key().as_bytes()) - .execute(state.db.pool()) - .await - .unwrap(); - check_membership_for_admission( - &state, - &tenant, - next_channel, - key.public_key().as_bytes(), - None, - ) - .await - .expect("fresh channel must pass the pre-fence membership check"); - let mut tx = state.db.pool().begin().await.unwrap(); - fences::insert(&mut tx, identity, &lease, None) - .await - .unwrap(); - tx.commit().await.unwrap(); - let (frames, _) = - run_audio_auth_in(state, tenant, next_channel, None, &key, true).await; - assert!( - frames - .iter() - .any(|frame| frame.contains("restricted for user deletion")), - "{frames:?}" - ); - server.abort(); - } - /// A ban closes a live audio socket: admission binds the socket to its /// pubkey, and the ban's pod-local disconnect /// (`AppState::disconnect_pubkey_local`) closes it with a policy close. diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index d104277f0f9..10847669749 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -46,7 +46,10 @@ pub(crate) async fn accept( &state.relay_keypair, ) .await - .map_err(super::ingest::event_write_error)?; + .map_err(|e| match e { + buzz_db::DbError::UserDeletionRestricted => super::ingest::event_write_error(e), + _ => IngestError::Internal(format!("artifact storage: {e}")), + })?; match outcome { ArtifactOutcome::Accepted(events) => { for stored in &events { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 445c96ddb68..6ee506f9e57 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -736,36 +736,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { - conn.send(RelayMessage::ok(&event_id_hex, true, "")); - } - Ok(false) => {} - Err(error) => { - let message = match error { - IngestError::Rejected(message) => message, - _ => "error: identity write lease unavailable".into(), - }; - conn.send(RelayMessage::ok(&event_id_hex, false, &message)); - } - } + handle_agent_observer_event(event, conn_id, &event_id_hex, conn, state).await; return; } @@ -829,19 +800,13 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, -) -> bool { +) { let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; match verify_result { @@ -1324,7 +1282,7 @@ async fn handle_agent_observer_event( false, &format!("invalid: {e}"), )); - return false; + return; } Err(_) => { conn.send(RelayMessage::ok( @@ -1332,7 +1290,7 @@ async fn handle_agent_observer_event( false, "error: internal error", )); - return false; + return; } } @@ -1345,19 +1303,20 @@ async fn handle_agent_observer_event( false, "invalid: observer frame timestamp outside ±5 minute freshness window", )); - return false; + return; } let route = match agent_observer_route(&event) { Ok(Some(route)) => route, Ok(None) => { // Unknown frame value — silently drop, no error to publisher. - return true; + conn.send(RelayMessage::ok(event_id_hex, true, "")); + return; } Err(message) => { reject("invalid"); conn.send(RelayMessage::ok(event_id_hex, false, &message)); - return false; + return; } }; @@ -1400,7 +1359,7 @@ async fn handle_agent_observer_event( false, "error: internal server error", )); - return false; + return; } } } @@ -1413,7 +1372,7 @@ async fn handle_agent_observer_event( false, "restricted: observer frame is not authorized for this agent owner", )); - return false; + return; } // Rate limit telemetry frames only (100/sec per agent). @@ -1427,7 +1386,7 @@ async fn handle_agent_observer_event( false, "rate-limited: observer frame rate exceeded (100/sec per agent)", )); - return false; + return; } } @@ -1453,7 +1412,7 @@ async fn handle_agent_observer_event( ); fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; - true + conn.send(RelayMessage::ok(event_id_hex, true, "")); } fn agent_observer_route(event: &Event) -> Result, String> { diff --git a/crates/buzz-relay/src/handlers/identity_write.rs b/crates/buzz-relay/src/handlers/identity_write.rs deleted file mode 100644 index 0708e10bf01..00000000000 --- a/crates/buzz-relay/src/handlers/identity_write.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Bound a user's multi-transaction or external action with the existing lease. -use super::ingest::{event_write_error, IngestError}; -use crate::state::AppState; -use buzz_core::TenantContext; -use nostr::PublicKey; -use std::{future::Future, sync::Arc}; - -pub(super) fn error(error: anyhow::Error) -> IngestError { - if matches!( - error.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ) { - event_write_error(buzz_db::DbError::UserDeletionRestricted) - } else { - tracing::warn!(%error, "identity write lease unavailable"); - IngestError::Internal("error: identity write lease unavailable".into()) - } -} - -pub(super) async fn run( - state: &Arc, - tenant: &TenantContext, - actor: PublicKey, - operation: &str, - action: impl Future>, -) -> Result { - let lease = - buzz_deletion::acquire_actor_serving_write(&state.db, tenant.community(), operation, actor) - .await - .map_err(error)?; - let result = match lease.protect_landed(action).await { - Ok((Err(denial), _)) => Ok(Err(denial)), - Ok((Ok(value), verified)) => verified.map(|()| Ok(value)).map_err(error), - Err(cause) => Err(error(cause)), - }; - let finished = lease.finish().await.map_err(error); - match result { - Ok(Ok(value)) => { - finished?; - Ok(value) - } - Ok(Err(error)) | Err(error) => { - if let Err(cleanup) = finished { - tracing::warn!( - ?cleanup, - "identity lease cleanup failed; expiry retains drain proof" - ); - } - Err(error) - } - } -} - -/// Fence a separately authenticated HTTP mutation for its complete lifetime. -#[allow(clippy::result_large_err)] // Axum handlers return their denial response directly. -pub(crate) async fn run_http( - state: &Arc, - tenant: &TenantContext, - actor: PublicKey, - operation: &str, - action: impl Future>, -) -> Result { - use axum::response::IntoResponse; - let denial = |error: anyhow::Error| { - let restricted = matches!( - error.downcast_ref::(), - Some(buzz_db::DbError::UserDeletionRestricted) - ); - if restricted { - ( - axum::http::StatusCode::FORBIDDEN, - "restricted: identity is restricted for user deletion", - ) - .into_response() - } else { - tracing::warn!(%error, "HTTP identity write lease unavailable"); - ( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - "identity write lease unavailable", - ) - .into_response() - } - }; - let lease = - buzz_deletion::acquire_actor_serving_write(&state.db, tenant.community(), operation, actor) - .await - .map_err(denial)?; - let result = match lease.protect_landed(action).await { - Ok((Err(response), _)) => Ok(Err(response)), - Ok((Ok(value), verified)) => verified.map(|()| Ok(value)).map_err(denial), - Err(cause) => Err(denial(cause)), - }; - let finished = lease.finish().await.map_err(denial); - match result { - Ok(Ok(value)) => { - finished?; - Ok(value) - } - Ok(Err(error)) | Err(error) => { - let _ = finished; - Err(error) - } - } -} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0d2edf60188..4c24b5c05a6 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2701,30 +2701,6 @@ async fn ingest_event_inner( // (commands, feedback, reports, moderation) so no write returns ahead of it. enforce_write_restriction(state, tenant, kind_u32, auth.pubkey()).await?; - super::identity_write::run( - state, - tenant, - *auth.pubkey(), - "event_ingest", - Box::pin(ingest_verified_event( - state, tracer, tenant, event, auth, now, - )), - ) - .await -} - -async fn ingest_verified_event( - state: &Arc, - tracer: &Arc, - tenant: &TenantContext, - event: Event, - auth: IngestAuth, - now: i64, -) -> Result { - let event_id_hex = event.id.to_hex(); - let kind_u32 = event_kind_u32(&event); - let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; - // 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) { @@ -3678,7 +3654,7 @@ async fn ingest_verified_event( .db .replace_addressable_event(tenant.community(), &event, channel_id) .await - .map_err(event_write_error)? + .map_err(|e| IngestError::Internal(format!("error: {e}")))? } else if is_parameterized_replaceable(kind_u32) { // NIP-33 parameterized replaceable — keyed by (kind, pubkey, d_tag). let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); @@ -3693,7 +3669,7 @@ async fn ingest_verified_event( .db .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) .await - .map_err(event_write_error)? + .map_err(|e| IngestError::Internal(format!("error: {e}")))? } else if let Some(spec) = canvas_revision_spec.as_ref() { // Canvas write carrying an optimistic-concurrency precondition. Plain // canvas writes (no `expected-revision` tag) fall through to the generic diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e59c6421860..a887bd60dba 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -24,7 +24,6 @@ pub mod event; pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). pub mod identity_archive; -pub(crate) mod identity_write; /// imeta tag validation helpers. pub mod imeta; /// Transport-neutral event ingestion pipeline. diff --git a/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs index 8272d4ed893..e01989d9ef0 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs @@ -1,47 +1,5 @@ -//! Expanded insertion coverage and owner-delegated HTTP regression tests. +//! Persistent insertion coverage through HTTP and WebSocket event handling. use super::*; -use base64::Engine; -use sha2::{Digest, Sha256}; - -async fn signed_http(f: &Fixture, key: &Keys, event: &Event, owner: Option<&Keys>) -> (u16, Value) { - let body = serde_json::to_vec(event).unwrap(); - let url = format!("https://{}/events", f.tenant.host()); - let auth = EventBuilder::new(Kind::Custom(27235), "") - .tags([ - Tag::parse(["u", &url]).unwrap(), - Tag::parse(["method", "POST"]).unwrap(), - Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]).unwrap(), - ]) - .sign_with_keys(key) - .unwrap(); - let mut request = reqwest::Client::new() - .post(format!("{}/events", f.url)) - .header("host", f.tenant.host()) - .header("content-type", "application/json") - .header( - "authorization", - format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD - .encode(serde_json::to_vec(&auth).unwrap()) - ), - ) - .body(body); - if let Some(owner) = owner { - request = request.header( - "x-auth-tag", - buzz_sdk::nip_oa::compute_auth_tag(owner, &key.public_key(), "").unwrap(), - ); - } - let response = request.send().await.unwrap(); - (response.status().as_u16(), response.json().await.unwrap()) -} - -async fn direct_member(f: &Fixture, key: &Keys) { - sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING") - .bind(f.tenant.community().as_uuid()).bind(key.public_key().to_hex()) - .execute(&f.pool).await.unwrap(); -} fn action(key: &Keys, channel: Uuid, kind: u16, content: &str, extra: Vec>) -> Event { let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; @@ -239,76 +197,6 @@ async fn expanded_actions_reject_fenced_humans_and_agents_without_partial_writes } } -#[tokio::test] -#[ignore = "requires Postgres"] -async fn delegated_http_refuses_failed_owner_link_but_preserves_independent_members() { - let f = Fixture::configured(true).await; - direct_member(&f, &f.user).await; - direct_member(&f, &f.peer).await; - // All have channel membership, but only the last has independent relay membership. - let fresh = Keys::generate(); - let allowed = Keys::generate(); - let independent = Keys::generate(); - let channel = create_channel( - &f.state, - &f.tenant, - &f.peer, - &[ - &["p", &fresh.public_key().to_hex()], - &["p", &allowed.public_key().to_hex()], - &["p", &independent.public_key().to_hex()], - ], - ) - .await; - direct_member(&f, &independent).await; - f.fence().await; - for (key, owner, accepted) in [ - (&fresh, &f.user, false), - (&allowed, &f.peer, true), - (&independent, &f.user, true), - ] { - let event = EventBuilder::new(Kind::Custom(9), Uuid::new_v4().to_string()) - .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) - .sign_with_keys(key) - .unwrap(); - let (status, body) = signed_http(&f, key, &event, Some(owner)).await; - assert_eq!( - status < 300 && body["accepted"] == true, - accepted, - "{status}: {body}" - ); - assert_eq!( - f.state - .db - .get_event_by_id(f.tenant.community(), event.id.as_bytes()) - .await - .unwrap() - .is_some(), - accepted - ); - let stored: Option> = sqlx::query_scalar( - "SELECT agent_owner_pubkey FROM users WHERE community_id=$1 AND pubkey=$2", - ) - .bind(f.tenant.community().as_uuid()) - .bind(key.public_key().as_bytes()) - .fetch_optional(&f.pool) - .await - .unwrap() - .flatten(); - if key.public_key() == allowed.public_key() { - assert_eq!( - stored.as_deref(), - Some(f.peer.public_key().as_bytes().as_slice()) - ); - } else { - assert!( - stored.is_none(), - "refused/independent identity must not be reassigned" - ); - } - } -} - #[tokio::test] #[ignore = "requires Postgres"] async fn concurrent_expanded_writes_wait_for_fencing_commit_or_rollback() { @@ -387,6 +275,3 @@ async fn concurrent_expanded_writes_wait_for_fencing_commit_or_rollback() { ); } } - -#[path = "user_deletion_remaining_postgres_tests.rs"] -mod remaining_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs index 94d0808d4d8..fbc4aeaf55f 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -25,20 +25,9 @@ struct Fixture { impl Fixture { async fn new() -> Self { - Self::configured(false).await - } - - async fn configured(strict: bool) -> Self { - let mut state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() .await .expect("Postgres and Redis"); - if strict { - let state = Arc::make_mut(&mut state); - let config = Arc::make_mut(&mut state.config); - config.require_auth_token = true; - config.require_relay_membership = true; - config.allow_nip_oa_auth = true; - } let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) .await .unwrap(); diff --git a/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs deleted file mode 100644 index 7f749395618..00000000000 --- a/crates/buzz-relay/src/handlers/user_deletion_remaining_postgres_tests.rs +++ /dev/null @@ -1,225 +0,0 @@ -use super::*; - -#[tokio::test] -#[ignore = "requires Postgres and Redis"] -async fn replacements_workflows_and_typing_refuse_fenced_users_and_agents() { - let f = Fixture::new().await; - let mut channels = Vec::new(); - for key in [&f.user, &f.agent, &f.peer] { - channels.push(create_channel(&f.state, &f.tenant, key, &[]).await); - } - for phase in 0..2 { - if phase == 1 { - f.fence().await; - } - for websocket in [false, true] { - for (actor, key) in [&f.user, &f.agent, &f.peer].into_iter().enumerate() { - let expected = phase == 0 || actor == 2; - let channel = channels[actor]; - let nonce = Uuid::new_v4().to_string(); - let timestamp = nostr::Timestamp::from( - nostr::Timestamp::now().as_secs() + phase * 4 + u64::from(websocket), - ); - let cases = [ - (0, format!("{{\"name\":\"{nonce}\"}}"), vec![]), - (10030, String::new(), vec![vec!["emoji".into(), "probe".into(), "https://example.invalid/probe.png".into()]]), - (30030, String::new(), vec![vec!["d".into(), nonce.clone()], vec!["emoji".into(), "probe".into(), "https://example.invalid/probe.png".into()]]), - (10003, String::new(), vec![vec!["e".into(), "ab".repeat(32)]]), - (30620, "name: Gate probe\ntrigger:\n on: webhook\nsteps:\n - id: noop\n action: send_message\n text: Never invoked\n".into(), vec![vec!["d".into(), Uuid::new_v4().to_string()], vec!["h".into(), channel.to_string()]]), - (20002, String::new(), vec![vec!["h".into(), channel.to_string()]]), - ]; - for (kind, content, tags) in cases { - let event = EventBuilder::new(Kind::Custom(kind), content) - .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) - .custom_created_at(timestamp) - .sign_with_keys(key) - .unwrap(); - let before = snapshot(&f).await; - let (accepted, reason) = f.send(key, &event, websocket).await; - assert_eq!( - accepted, expected, - "kind {kind}, actor {actor}, WS {websocket}: {reason}" - ); - if !expected { - assert_eq!( - reason, - "restricted: identity is restricted for user deletion" - ); - assert_eq!(snapshot(&f).await, before); - } - assert_eq!( - f.state - .db - .get_event_by_id(f.tenant.community(), event.id.as_bytes()) - .await - .unwrap() - .is_some(), - expected && kind != 20002 - ); - } - } - } - } -} - -#[tokio::test] -#[ignore = "requires Postgres and Redis"] -async fn existing_workflow_actions_refuse_fenced_owners() { - let f = Fixture::new().await; - let action = buzz_workflow::ActionDef::Delay { - duration: "0s".into(), - }; - let step = buzz_workflow::Step { - id: "probe".into(), - name: None, - if_expr: None, - timeout_secs: None, - action: action.clone(), - }; - let mut runs = Vec::new(); - for key in [&f.user, &f.agent, &f.peer] { - f.state - .db - .ensure_user(f.tenant.community(), key.public_key().as_bytes()) - .await - .unwrap(); - let workflow = f - .state - .db - .create_workflow( - f.tenant.community(), - Some(f.channel), - key.public_key().as_bytes(), - "existing", - "{}", - &[0; 32], - ) - .await - .unwrap(); - runs.push( - f.state - .db - .create_workflow_run(f.tenant.community(), workflow, None, None) - .await - .unwrap(), - ); - } - for phase in 0..2 { - if phase == 1 { - f.fence().await; - } - for (index, run) in runs.iter().enumerate() { - let result = buzz_workflow::executor::dispatch_action( - &step, - &action, - &f.state.workflow_engine, - f.tenant.community(), - *run, - &Default::default(), - ) - .await; - assert_eq!(result.is_ok(), phase == 0 || index == 2, "{result:?}"); - if phase == 1 && index != 2 { - assert!(result - .unwrap_err() - .to_string() - .contains("restricted for user deletion")); - } - } - } -} - -#[tokio::test] -#[ignore = "requires Postgres and Redis"] -async fn upload_routes_check_actor_before_reading_body() { - let f = Fixture::new().await; - // Deliberately unsupported audio proves admission reached body validation - // before fencing; after fencing the very same signed input must be 403. - let body = b"ID3\x04\x00\x00\x00\x00\x00\x00"; - for phase in 0..2 { - if phase == 1 { - f.fence().await; - } - for path in ["/upload", "/media/upload"] { - for (index, key) in [&f.user, &f.agent, &f.peer].iter().enumerate() { - let sha = hex::encode(Sha256::digest(body)); - let event = EventBuilder::new(Kind::Custom(24242), Uuid::new_v4().to_string()) - .tags([ - Tag::parse(["t", "upload"]).unwrap(), - Tag::parse(["server", f.tenant.host()]).unwrap(), - Tag::parse(["x", &sha]).unwrap(), - Tag::parse([ - "expiration", - &(nostr::Timestamp::now().as_secs() + 55).to_string(), - ]) - .unwrap(), - ]) - .sign_with_keys(key) - .unwrap(); - let response = reqwest::Client::new() - .put(format!("{}{path}", f.url)) - .header("host", f.tenant.host()) - .header("content-type", "audio/mpeg") - .header("x-sha-256", &sha) - .header( - "authorization", - format!( - "Nostr {}", - base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&event).unwrap()) - ), - ) - .body(body.to_vec()) - .send() - .await - .unwrap(); - let status = response.status().as_u16(); - let text = response.text().await.unwrap(); - assert_eq!( - status, - if phase == 1 && index != 2 { 403 } else { 415 }, - "{path} {index}: {text}" - ); - if status == 403 { - assert!(text.contains("restricted for user deletion"), "{text}"); - } - } - } - } -} - -#[tokio::test] -#[ignore = "requires Postgres and Redis"] -async fn personal_read_markers_refuse_fenced_actor_even_for_idempotent_retries() { - use buzz_db::personal_read::{IntentOutcome, ReadIntent, ReadTarget}; - let f = Fixture::new().await; - let message = f.message(&f.peer, 9, None); - assert!(f.send(&f.peer, &message, false).await.0); - let intent = ReadIntent::MarkThrough { - target: ReadTarget { - channel_id: f.channel, - root_id: None, - }, - message_id: message.id.to_hex(), - }; - for phase in 0..2 { - if phase == 1 { - f.fence().await; - } - for (index, key) in [&f.user, &f.agent, &f.peer].iter().enumerate() { - let result = f - .state - .db - .apply_personal_read_intent(f.tenant.community(), &key.public_key(), &intent) - .await; - if phase == 1 && index != 2 { - assert!( - matches!(result, Err(buzz_db::DbError::UserDeletionRestricted)), - "{result:?}" - ); - } else { - assert_eq!(result.unwrap(), IntentOutcome::Applied); - } - } - } -} diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index dd5d2f0bf1b..90a6a02e020 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -550,30 +550,14 @@ pub async fn dispatch_action( // Revalidate the durable community fence immediately before every external // side effect (message publish, webhook, delay/resume). A storage failure is // a denial, never permission to continue. - let owner_run = engine - .db - .get_workflow_run(community_id, run_id) - .await - .map_err(|e| WorkflowError::WebhookError(format!("workflow run lookup failed: {e}")))?; - let owner_workflow = engine - .db - .get_workflow(community_id, owner_run.workflow_id) - .await - .map_err(|e| WorkflowError::WebhookError(format!("workflow owner lookup failed: {e}")))?; - let owner = nostr::PublicKey::from_slice(&owner_workflow.owner_pubkey) - .map_err(|e| WorkflowError::Unauthorized(format!("invalid workflow owner: {e}")))?; - let serving_write = buzz_deletion::acquire_actor_serving_write( - &engine.db, - community_id, - "workflow_action", - owner, - ) - .await - .map_err(|error| { - WorkflowError::WebhookError(format!( - "community write fence rejected workflow side effect: {error}" - )) - })?; + let serving_write = + buzz_deletion::acquire_serving_write(&engine.db, community_id, "workflow_action") + .await + .map_err(|error| { + WorkflowError::WebhookError(format!( + "community write fence rejected workflow side effect: {error}" + )) + })?; serving_write.verify().await.map_err(|error| { WorkflowError::WebhookError(format!("community write lease lost: {error}")) diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md index 84c847d6d4f..30a8d582c4e 100644 --- a/docs/user-deletion-message-fencing.md +++ b/docs/user-deletion-message-fencing.md @@ -1,69 +1,39 @@ -# User-deletion fencing: serving integration +# User-deletion fencing: event database writes -This extends the restriction storage from PR #8632 to serving writes. It does not enable the real user-deletion worker. Do not install production fences until inventory, quiescing, worker integration and every writer deployment are complete. +This PR checks existing restrictions in the two shared event insertion functions in `buzz-db/src/store/event.rs`: `insert_event_on` and `insert_event_with_thread_metadata_tx`. Every event kind reaching those functions is checked. HTTP and WebSocket event handlers report the restriction as a client refusal. -## Identity contract +## Transaction flow -Human restrictions apply to a public key across hosted communities. Agent restrictions apply to a public key within one community. Serving code checks the signer's global and scoped restrictions. It locks the stored owner's identity for coordination; the owner's restriction is not an additional authorization check for an independently admitted agent. +1. Enter the existing community-admitted database transaction. +2. Read the event author's stored owner in this community, if present. +3. Acquire shared global author/owner identity locks and the scoped author lock in canonical order. +4. Read the owner again. If it changed while acquiring locks, reject for retry in a new transaction. +5. Read the author's global and community-scoped restrictions. Reject a restricted author. +6. Persist the event and its transactional derived changes. Commit or rollback releases the locks. -Restrictions survive ordinary profile deletion. An independently admitted agent with no stored owner or own restriction remains allowed, even if it supplies an optional owner credential. When HTTP admission depends on delegation, required owner linking must succeed before ingestion. +Human restrictions are global by public key; agent restrictions are community-scoped. The author's own restriction decides whether the event is rejected. An owner's restriction is not an additional authorization check for an independently admitted agent. Restrictions still apply when profile rows are absent or recreated. -## Coverage +The writer uses the existing identity lock keys used by restriction insertion. If an event transaction wins first, the fence waits for it to finish. If the fence commits first, a waiting event sees the restriction and is rejected. If the fence rolls back, the event may proceed. No user-specific lease is involved. -| Surface | Protection | -| --- | --- | -| Persistent events, including replies, reactions, edits, channel changes and deletion commands | Shared identity locks in persistence, plus an actor lease around ingestion and its side effects | -| Replaceable profiles, emoji collections, bookmarks and workflow definitions | Guarded replacement functions and ingestion actor lease | -| Personal read-state mutations | Identity guard in the write transaction, including idempotent retries | -| WebSocket ephemeral activity and observer frames; HTTP typing | Actor lease through publication and final verification | -| Uploads on both routes | Actor lease around the upload, including final verification | -| Git publication and default-branch mutation | Actor lease around the protected publication/mutation | -| Workflow actions | Durable workflow owner lookup and actor lease before each action | -| Invitation mint/claim and GIF share | Actor lease around the authenticated mutation | -| Huddle audio | One actor lease per session; lease loss cancels the connection | +## Scope -Ordinary reads remain available. Affected-user checks, such as Other User adding Requested User to a channel, are separate work. This is not a claim that all background jobs or external agent processes are cancelled. +The guard covers persistence through these two functions, including messages, replies, reactions, edits, votes, canvas, channel changes, deletion events and artifacts. Tests exercise actual callers and check that rejection rolls back transactional domain rows and reply counters. -## Transactional writes +Separate replaceable-event writers (profiles, emoji collections, bookmarks and workflow definitions), personal read state, uploads, git, audio and ephemeral activity are outside this PR. Authentication, agent creation/owner assignment, affected-user checks and background work are also separate work. This is not complete user-activity fencing. -1. Acquire the existing shared community deletion guard. -2. Read the signer's stored owner in this community. -3. Acquire shared global signer/owner locks in canonical order, then the scoped signer lock. -4. Read ownership again. If it changed while waiting, refuse the operation for retry rather than extending the lock set out of order. -5. Read the signer's global and scoped restriction rows. Refuse a restricted signer. -6. Write the event and transactional derived records; commit or rollback releases the locks. +There are no schema changes, lease changes, or changes to restriction insertion behavior. The existing restriction reader is exposed within the database module so event writers can read after acquiring the full ordered lock set. Tests install restrictions through the previously merged helper solely as fixture setup. -The restriction reads are ordinary indexed reads. No restriction cache is used. First-owner assignment coordinates through the scoped exclusive agent lock and refuses restricted prospective owners and agents. Both the ordinary setter and invitation claims use this guard; invitation paths acquire their existing admission lock first. Refused claims roll back membership, owner rows and invitation usage together. - -## Operations spanning transactions or external effects - -The existing `community_serving_write_leases` table gains nullable `actor_pubkey`. Actor lease acquisition checks restrictions while holding shared identity locks, then records the lease in that same transaction. Heartbeats and final verification also check restrictions. No database connection is retained for the entire external operation. - -Fence insertion validates the approved request and worker lease, acquires exclusive identity locks, then checks for live actor leases matching the restriction's scope. If one exists, insertion returns `identity writes are still in flight; retry fencing`. The caller must roll back the transaction and retry later; it must not sleep while holding the exclusive locks. - -This handles both orders: an admitted operation leaves a lease that prevents fencing, or fencing commits first and the new operation is rejected. It reuses the existing lease heartbeat, expiry and cancellation mechanisms. It does not provide a remote storage service with transactional rollback or stop a remote effect already dispatched before lease loss. - -A live audio session can keep its lease alive. The future deletion workflow must quiesce/disconnect sessions before fencing can complete. This PR does not add that orchestration. A worker installing human and owned-agent restrictions must still do so atomically under the complete ordered lock set and save its checkpoint in the same transaction. - -## Deployment and performance - -Apply migration `0076_serving_write_actor.sql` before deploying code that reads/writes the new column. Desired-schema installations obtain the column and partial actor index from the table definition. Old community-only writers can leave the column NULL, but cannot protect user deletion; deploy the actor-aware code to every writer before enabling real fencing. - -The actor lease adds database writes and checks to serving operations, including ephemeral events. No production throughput or latency benchmark is claimed. Schema rollout and serving-path overhead need review. +The real user-deletion worker remains disabled. Before enabling it, ownership/admission writes and other serving paths must be coordinated, and the worker must atomically fence the user and the approved owned-agent set. The owner recheck here only detects changes observed while obtaining locks; it does not implement a complete ownership-assignment protocol. ## Validation -The local relay simulation uses synthetic identities across three communities and real HTTP/WebSocket requests, PostgreSQL, Redis and MinIO. The fresh matrix ran 510 supported cases before fencing and 510 afterward: 272 expected refusals and 238 allowed controls after fencing. Fifteen HTTP presence observations per phase are unsupported and excluded. These are action/actor/community/transport combinations, not 510 distinct features. - -The matrix covers reactions, edits, votes, canvas, channel settings/TTL/membership/creation, artifacts, profiles, emoji lists/sets, bookmarks, workflow creation, typing, WebSocket presence, both upload routes and read-state. It verifies persistence for event writes. The same agent remains allowed in a community where it belongs to a different owner. - -Separate production-path regressions cover messages and reply counters, conditional canvas, message/workflow/channel deletion with unchanged database snapshots on refusal, existing workflow actions, HTTP delegated ownership, actor lease races, and audio disconnect/fence/new-join. Git tests exercise finalization with real repositories and local MinIO, including publication records and unchanged storage on refusal; they are not a complete remote CLI push test. +Regression tests cover global/scoped restrictions, unaffected users and communities, missing profiles, fence commit/rollback, writes already in flight, observed ownership changes, and the channel-TTL lock interleaving. Relay tests exercise signed HTTP requests and WebSocket EVENT handling before and after restrictions, including replies and unchanged database snapshots on refusal. -Reproduce the focused PostgreSQL checks with isolated test PostgreSQL and Redis configured: +With isolated PostgreSQL and Redis configured: ```sh -scripts/postgres-test-run.sh -p buzz-db -p buzz-deletion -p buzz-relay --lib \ - -E 'test(/user_deletion/)' --test-threads 2 --no-fail-fast +scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib \ + -E 'test(/user_deletion_write_guard|user_deletion_message/)' --test-threads 4 ``` -The live experiment uses approved synthetic requests and production fence helpers. It does not execute a real deletion inventory/workflow, delete user data, or validate production authentication/deployment at fleet scale. Repository-wide validation status is recorded in the PR; a passing live matrix alone is not a passing full suite. +The earlier 510-case live matrix validated a broader revision that included leases. It is not evidence of this narrowed revision's scope or coverage. Current validation results belong in the PR description. diff --git a/migrations/0076_serving_write_actor.sql b/migrations/0076_serving_write_actor.sql deleted file mode 100644 index 4687517e212..00000000000 --- a/migrations/0076_serving_write_actor.sql +++ /dev/null @@ -1,10 +0,0 @@ -SET LOCAL lock_timeout = '5s'; - --- Additive: legacy community-only writers may leave this NULL. Roll out actor --- admission to every relay before enabling real user deletion. Existing lease --- expiry/reaping and whole-community deletion retain their current behavior. -ALTER TABLE community_serving_write_leases ADD COLUMN actor_pubkey TEXT; - -CREATE INDEX community_serving_write_leases_actor_active - ON community_serving_write_leases (actor_pubkey, community_id, lease_until) - WHERE actor_pubkey IS NOT NULL; diff --git a/schema/tables/public/community_serving_write_leases.sql b/schema/tables/public/community_serving_write_leases.sql index bfed72ebfb6..6a905fe6b47 100644 --- a/schema/tables/public/community_serving_write_leases.sql +++ b/schema/tables/public/community_serving_write_leases.sql @@ -2,8 +2,6 @@ CREATE TABLE community_serving_write_leases ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), community_id UUID NOT NULL REFERENCES communities(id), operation TEXT NOT NULL, - -- Optional authenticated actor; no profile FK, survives profile cleanup. - actor_pubkey TEXT, owner TEXT NOT NULL, generation BIGINT NOT NULL DEFAULT 1 CHECK (generation > 0), -- Community fence generation observed when this lease was acquired. @@ -14,7 +12,3 @@ CREATE TABLE community_serving_write_leases ( ); CREATE INDEX community_serving_write_leases_active ON community_serving_write_leases (community_id, lease_until); - -CREATE INDEX community_serving_write_leases_actor_active - ON community_serving_write_leases (actor_pubkey, community_id, lease_until) - WHERE actor_pubkey IS NOT NULL; From eb2c2c8cb8db40b1ddccb58d548e40a7a5b82a61 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:14:33 -0400 Subject: [PATCH 05/11] Combine signer restriction lookups Signed-off-by: Ravneet Arora --- .../buzz-db/src/store/user_deletion_fences.rs | 3 +-- .../src/store/user_deletion_write_guard.rs | 18 ++++++++++-------- ...user_deletion_write_guard_postgres_tests.rs | 9 +++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/buzz-db/src/store/user_deletion_fences.rs b/crates/buzz-db/src/store/user_deletion_fences.rs index dcf221baf3a..3b39ca593a1 100644 --- a/crates/buzz-db/src/store/user_deletion_fences.rs +++ b/crates/buzz-db/src/store/user_deletion_fences.rs @@ -162,8 +162,7 @@ pub async fn find( read(tx, identity).await } -/// Read one restriction after the caller has acquired its identity lock. -pub(super) async fn read( +async fn read( tx: &mut Transaction<'_, Postgres>, identity: FenceIdentity, ) -> Result> { diff --git a/crates/buzz-db/src/store/user_deletion_write_guard.rs b/crates/buzz-db/src/store/user_deletion_write_guard.rs index b3cbf549629..fd270900ae0 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard.rs @@ -6,7 +6,7 @@ use buzz_core::CommunityId; use nostr::{Event, PublicKey}; use sqlx::{Postgres, Transaction}; -use super::user_deletion_fences::{lock_shared, read as read_locked, FenceIdentity}; +use super::user_deletion_fences::{lock_shared, FenceIdentity}; use crate::{DbError, Result}; pub(crate) async fn guard_event_author( @@ -63,13 +63,15 @@ async fn check_signer( // Read the signer's restrictions, not the owner's. Check both possible // scopes even if the profile/ownership row is absent: those mutable rows // must not determine whether a permanent restriction still applies. - if read_locked(tx, FenceIdentity::user(signer)) - .await? - .is_some() - || read_locked(tx, FenceIdentity::agent(community, signer)) - .await? - .is_some() - { + let restricted: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM user_deletion_fences + WHERE pubkey = $1 AND (scope_community_id IS NULL OR scope_community_id = $2))", + ) + .bind(signer.to_hex()) + .bind(community.as_uuid()) + .fetch_one(&mut **tx) + .await?; + if restricted { return Err(DbError::UserDeletionRestricted); } Ok(()) diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs index e1cc52ed252..a03a386cfb2 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -217,6 +217,15 @@ async fn message_writers_enforce_global_and_scoped_fences_even_without_profiles( f.db.insert_event_with_thread_metadata(f.community, &message(&agent, 9), None, None) .await, ); + // Both scopes may match the same signer. The existence lookup must still + // refuse, and the global restriction must also cover another community. + f.fence(&agent, None).await; + for community in [f.community, other_community] { + restricted( + f.db.insert_event(community, &message(&agent, 9), None) + .await, + ); + } } #[tokio::test] From f1570c422c7533da47dd0d5559df019600245369 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:21:09 -0400 Subject: [PATCH 06/11] Preserve retryable identity write errors Signed-off-by: Ravneet Arora --- crates/buzz-db/src/error.rs | 4 + .../src/store/user_deletion_write_guard.rs | 6 +- ...ser_deletion_write_guard_postgres_tests.rs | 7 +- crates/buzz-relay/src/handlers/artifact.rs | 6 +- .../src/handlers/command_executor.rs | 6 +- crates/buzz-relay/src/handlers/ingest.rs | 87 +++++++------- .../user_deletion_error_postgres_tests.rs | 106 ++++++++++++++++++ .../user_deletion_message_postgres_tests.rs | 25 ++++- 8 files changed, 193 insertions(+), 54 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index dff8cbada71..81e1d5ca242 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -41,6 +41,10 @@ pub enum DbError { #[error("identity is restricted for user deletion")] UserDeletionRestricted, + /// Ownership changed while acquiring event-author locks; retry in a new transaction. + #[error("agent ownership changed; retry the write")] + EventAuthorOwnershipChanged, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, diff --git a/crates/buzz-db/src/store/user_deletion_write_guard.rs b/crates/buzz-db/src/store/user_deletion_write_guard.rs index fd270900ae0..af08284859d 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard.rs @@ -27,10 +27,8 @@ pub(crate) async fn guard_event_author( lock_shared(tx, &identities).await?; if owner != self::owner(tx, community, signer).await? { // Restart in a new transaction: adding another owner lock now could - // invert the global-before-scoped ordering. No write has occurred. - return Err(DbError::AccessDenied( - "agent ownership changed; retry the write".into(), - )); + // invert the global-before-scoped ordering. The caller must roll back. + return Err(DbError::EventAuthorOwnershipChanged); } check_signer(tx, community, signer).await } diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs index a03a386cfb2..ac9e849e1e4 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -359,9 +359,10 @@ async fn ownership_change_while_waiting_rejects_and_retry_uses_current_owner() { .await .unwrap(); assignment.commit().await.unwrap(); - assert!( - matches!(sending.await.unwrap(), Err(DbError::AccessDenied(reason)) if reason.contains("ownership changed")) - ); + assert!(matches!( + sending.await.unwrap(), + Err(DbError::EventAuthorOwnershipChanged) + )); assert!(f .db .get_event_by_id(f.community, event.id.as_bytes()) diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 10847669749..afbcd931c06 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -46,9 +46,9 @@ pub(crate) async fn accept( &state.relay_keypair, ) .await - .map_err(|e| match e { - buzz_db::DbError::UserDeletionRestricted => super::ingest::event_write_error(e), - _ => IngestError::Internal(format!("artifact storage: {e}")), + .map_err(|e| { + super::ingest::event_write_error(&e) + .unwrap_or_else(|| IngestError::Internal(format!("artifact storage: {e}"))) })?; match outcome { ArtifactOutcome::Accepted(events) => { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 86d8ae86174..19baf278bab 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -184,9 +184,9 @@ async fn persist_command_event( let (_, was_inserted) = buzz_db::event::insert_event_in_transaction(&mut tx, event, channel_id) .await - .map_err(|e| match e { - buzz_db::DbError::UserDeletionRestricted => super::ingest::event_write_error(e), - _ => IngestError::Internal(format!("error: insert event: {e}")), + .map_err(|e| { + super::ingest::event_write_error(&e) + .unwrap_or_else(|| IngestError::Internal(format!("error: insert event: {e}"))) })?; if was_inserted { Ok(PersistResult::Inserted(tx)) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e92aafb879e..9e6988737e1 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -472,13 +472,16 @@ pub enum BanRefusal { Unavailable, } -/// Keep durable identity restrictions as client refusals on every insert path. -pub(crate) fn event_write_error(error: buzz_db::DbError) -> IngestError { +/// Classify identity-fencing failures without changing unrelated write errors. +pub(crate) fn event_write_error(error: &buzz_db::DbError) -> Option { match error { - buzz_db::DbError::UserDeletionRestricted => { - IngestError::Rejected("restricted: identity is restricted for user deletion".into()) - } - other => IngestError::Internal(format!("error: {other}")), + buzz_db::DbError::UserDeletionRestricted => Some(IngestError::Rejected( + "restricted: identity is restricted for user deletion".into(), + )), + buzz_db::DbError::EventAuthorOwnershipChanged => Some(IngestError::Internal( + "error: agent ownership changed; retry the write".into(), + )), + _ => None, } } @@ -3476,8 +3479,10 @@ async fn ingest_event_inner( emoji, ) .await - .map_err(event_write_error)? - { + .map_err(|e| { + event_write_error(&e) + .unwrap_or_else(|| IngestError::Internal(format!("error: {e}"))) + })? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected(format!( "invalid: {REACTION_TARGET_NOT_FOUND}" @@ -3568,8 +3573,10 @@ async fn ingest_event_inner( .db .create_channel_with_event(tenant.community(), create.channel_id, channel, &event) .await - .map_err(event_write_error)? - { + .map_err(|e| { + event_write_error(&e) + .unwrap_or_else(|| IngestError::Internal(format!("error: {e}"))) + })? { buzz_db::channel::ChannelCreate::Created(stored) => { metrics::counter!( "buzz_channels_created_total", @@ -3600,11 +3607,12 @@ async fn ingest_event_inner( // divide insertion and repair ownership between two relay workers. crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state) .await - .map_err(|e| match e.downcast_ref::() { - Some(buzz_db::DbError::UserDeletionRestricted) => { - event_write_error(buzz_db::DbError::UserDeletionRestricted) - } - _ => IngestError::Internal(format!("error: workflow deletion failed: {e}")), + .map_err(|e| { + e.downcast_ref::() + .and_then(event_write_error) + .unwrap_or_else(|| { + IngestError::Internal(format!("error: workflow deletion failed: {e}")) + }) })? } else if let Some(plan) = put_user { // One commit stores the event and applies its membership changes. A @@ -3620,14 +3628,15 @@ async fn ingest_event_inner( thread_params, ) .await - .map_err(|e| match e.downcast_ref::() { - Some(buzz_db::DbError::UserDeletionRestricted) => { - event_write_error(buzz_db::DbError::UserDeletionRestricted) - } - Some(buzz_db::DbError::AccessDenied(reason)) => { - IngestError::Rejected(format!("invalid: {reason}")) - } - _ => IngestError::Internal(format!("error: membership write failed: {e}")), + .map_err(|e| { + e.downcast_ref::() + .and_then(event_write_error) + .unwrap_or_else(|| match e.downcast_ref::() { + Some(buzz_db::DbError::AccessDenied(reason)) => { + IngestError::Rejected(format!("invalid: {reason}")) + } + _ => IngestError::Internal(format!("error: membership write failed: {e}")), + }) })?; message = ok_message; (stored, inserted) @@ -3638,14 +3647,15 @@ async fn ingest_event_inner( // that is not archived) is the client's error; anything else is ours. crate::handlers::side_effects::persist_edit_metadata(tenant, &event, state) .await - .map_err(|e| match e.downcast_ref::() { - Some(buzz_db::DbError::UserDeletionRestricted) => { - event_write_error(buzz_db::DbError::UserDeletionRestricted) - } - Some(buzz_db::DbError::AccessDenied(reason)) => { - IngestError::Rejected(format!("invalid: {reason}")) - } - _ => IngestError::Internal(format!("error: channel update failed: {e}")), + .map_err(|e| { + e.downcast_ref::() + .and_then(event_write_error) + .unwrap_or_else(|| match e.downcast_ref::() { + Some(buzz_db::DbError::AccessDenied(reason)) => { + IngestError::Rejected(format!("invalid: {reason}")) + } + _ => IngestError::Internal(format!("error: channel update failed: {e}")), + }) })? } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. @@ -3686,7 +3696,10 @@ async fn ingest_event_inner( .db .insert_canvas_head_checked(tenant.community(), &event, channel, precondition) .await - .map_err(event_write_error)?; + .map_err(|e| { + event_write_error(&e) + .unwrap_or_else(|| IngestError::Internal(format!("error: {e}"))) + })?; match status { buzz_db::ChannelHeadWriteStatus::RevisionMissing => { return Err(IngestError::CanvasConflict( @@ -3724,13 +3737,9 @@ async fn ingest_event_inner( buzz_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) } - buzz_db::DbError::UserDeletionRestricted => IngestError::Rejected( - "restricted: identity is restricted for user deletion".into(), - ), - buzz_db::DbError::AccessDenied(reason) => { - IngestError::Rejected(format!("restricted: {reason}")) - } - other => IngestError::Internal(format!("error: database error: {other}")), + other => event_write_error(&other).unwrap_or_else(|| { + IngestError::Internal(format!("error: database error: {other}")) + }), }); } } diff --git a/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs new file mode 100644 index 00000000000..39b8aa67c20 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs @@ -0,0 +1,106 @@ +use super::*; + +#[test] +fn identity_error_mapping_preserves_unrelated_failures() { + use crate::handlers::ingest::{event_write_error, IngestError}; + assert!( + matches!(event_write_error(&buzz_db::DbError::UserDeletionRestricted), + Some(IngestError::Rejected(message)) if message.starts_with("restricted:")) + ); + assert!( + matches!(event_write_error(&buzz_db::DbError::EventAuthorOwnershipChanged), + Some(IngestError::Internal(message)) if message.starts_with("error:")) + ); + for error in [ + buzz_db::DbError::AccessDenied("community is write-fenced (deleting)".into()), + buzz_db::DbError::AccessDenied( + "deletion-lock admission requires read committed isolation".into(), + ), + buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut), + ] { + assert!(event_write_error(&error).is_none()); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ownership_races_are_retryable_over_http_and_websocket() { + for websocket in [false, true] { + for kind in [9, 9000, 9002] { + let f = Arc::new(Fixture::new().await); + f.state + .db + .ensure_user(f.tenant.community(), f.peer.public_key().as_bytes()) + .await + .unwrap(); + let channel = create_channel(&f.state, &f.tenant, &f.agent, &[]).await; + let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; + if kind == 9000 { + tags.push(Tag::parse(["p", &f.peer.public_key().to_hex(), "member"]).unwrap()); + } else if kind == 9002 { + tags.push(Tag::parse(["name", "renamed after retry"]).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(kind), "owner race") + .tags(tags) + .sign_with_keys(&f.agent) + .unwrap(); + let mut assignment = f.pool.begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *assignment) + .await + .unwrap(); + fences::lock_exclusive( + &mut assignment, + &[FenceIdentity::agent( + f.tenant.community(), + f.agent.public_key(), + )], + ) + .await + .unwrap(); + let writer_fixture = f.clone(); + let pending = event.clone(); + let sending = tokio::spawn(async move { + writer_fixture + .submit(&writer_fixture.agent, &pending, websocket) + .await + }); + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))") + .bind(pid).fetch_one(&f.pool).await.unwrap(); + if blocked { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("writer must reach the production identity guard"); + sqlx::query( + "UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3", + ) + .bind(f.peer.public_key().to_bytes().as_slice()) + .bind(f.tenant.community().as_uuid()) + .bind(f.agent.public_key().to_bytes().as_slice()) + .execute(&mut *assignment) + .await + .unwrap(); + assignment.commit().await.unwrap(); + let (accepted, message, status) = sending.await.unwrap(); + assert!(!accepted); + if websocket { + assert_eq!(message, "error: internal server error"); + } else { + assert_eq!(status, Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR)); + assert_eq!(message, "internal server error"); + } + assert!(f + .state + .db + .get_event_by_id(f.tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_none()); + let (accepted, message) = f.send(&f.agent, &event, websocket).await; + assert!(accepted, "kind {kind}: {message}"); + } + } +} diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs index fbc4aeaf55f..e9b36952b05 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -139,6 +139,19 @@ impl Fixture { } async fn send(&self, key: &Keys, event: &Event, websocket: bool) -> (bool, String) { + let (accepted, message, status) = self.submit(key, event, websocket).await; + if let Some(status) = status.filter(|s| !s.is_success()) { + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{message}"); + } + (accepted, message) + } + + async fn submit( + &self, + key: &Keys, + event: &Event, + websocket: bool, + ) -> (bool, String, Option) { if websocket { let (conn, mut rx) = test_conn(&self.tenant, key); if let crate::connection::AuthState::Authenticated(ctx) = @@ -158,6 +171,7 @@ impl Fixture { ( frame[2].as_bool().unwrap(), frame[3].as_str().unwrap().to_string(), + None, ) } else { let response = reqwest::Client::new() @@ -174,10 +188,14 @@ impl Fixture { ( body["accepted"].as_bool().unwrap(), body["message"].as_str().unwrap_or("").to_owned(), + Some(status), ) } else { - assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); - (false, body["error"].as_str().unwrap().to_owned()) + ( + false, + body["error"].as_str().unwrap().to_owned(), + Some(status), + ) } } } @@ -240,3 +258,6 @@ async fn http_and_websocket_messages_honor_user_and_agent_fences() { #[path = "user_deletion_expanded_postgres_tests.rs"] mod expanded_postgres_tests; + +#[path = "user_deletion_error_postgres_tests.rs"] +mod error_postgres_tests; From 46880526612cb7aa9aea76b0a8a74cf7298b5bb2 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:35:28 -0400 Subject: [PATCH 07/11] Persist identity archive requests atomically Signed-off-by: Ravneet Arora --- .../buzz-db/src/store/archived_identities.rs | 100 +++++- .../src/handlers/identity_archive.rs | 161 +++++----- crates/buzz-relay/src/handlers/ingest.rs | 32 +- .../buzz-relay/src/handlers/side_effects.rs | 92 +----- .../user_deletion_archive_postgres_tests.rs | 285 ++++++++++++++++++ .../user_deletion_message_postgres_tests.rs | 3 + 6 files changed, 505 insertions(+), 168 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index 986985bc464..c1c337913ab 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -5,13 +5,14 @@ //! relay access, or repository permissions. //! All pubkey and event ID values are lowercase hex strings. -use buzz_core::CommunityId; +use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; +use nostr::Event; use sqlx::{PgPool, Row as _}; use crate::error::Result; -use crate::Db; +use crate::{AdmittedTx, Db}; /// A single archived identity record. #[derive(Debug, Clone)] @@ -70,6 +71,31 @@ pub async fn archive( crate::observability::WriterOperation::EventWrite, ) .await?; + let changed = archive_on( + &mut tx, + pubkey, + consent_path, + actor, + reason, + replaced_by, + request_event_id, + ) + .await?; + tx.commit().await?; + Ok(changed) +} + +#[allow(clippy::too_many_arguments)] +async fn archive_on( + tx: &mut AdmittedTx, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, +) -> Result { + let community_id = tx.community(); let result = sqlx::query( "INSERT INTO archived_identities \ (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ @@ -85,7 +111,6 @@ pub async fn archive( .bind(request_event_id) .execute(tx.conn()) .await?; - tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -100,13 +125,19 @@ pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) - crate::observability::WriterOperation::EventWrite, ) .await?; + let changed = unarchive_on(&mut tx, pubkey).await?; + tx.commit().await?; + Ok(changed) +} + +async fn unarchive_on(tx: &mut AdmittedTx, pubkey: &str) -> Result { + let community_id = tx.community(); let result = sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) .execute(tx.conn()) .await?; - tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -148,7 +179,68 @@ fn row_to_archived_identity( }) } +/// Validated archive mutation accompanying a signed identity request. +pub struct IdentityArchiveChange<'a> { + /// Identity whose archive state changes. + pub target: &'a str, + /// Whether to archive (`true`) or unarchive (`false`). + pub archive: bool, + /// Validated consent path: self, owner, or admin. + pub consent_path: &'a str, + /// Optional reason supplied by the actor. + pub reason: Option<&'a str>, + /// Optional replacement identity. + pub replaced_by: Option<&'a str>, +} + impl Db { + /// Atomically persist the signed request, archive mutation, and relay delta. + /// + /// The request passes the shared author fence before any archive mutation. + /// Duplicate requests never reapply a mutation; the persisted delta is + /// returned for post-commit delivery when newly inserted. Stored deltas + /// remain available for replay; duplicate requests emit no new delta. + pub async fn apply_identity_archive_event( + &self, + community: CommunityId, + request: &Event, + change: IdentityArchiveChange<'_>, + delta: &Event, + ) -> Result<(StoredEvent, bool, Option)> { + let mut tx = self.begin_event_write_transaction(community).await?; + let (stored, inserted) = + crate::event::insert_event_in_transaction(&mut tx, request, None).await?; + let notification = if inserted { + let changed = if change.archive { + archive_on( + &mut tx, + change.target, + change.consent_path, + &request.pubkey.to_hex(), + change.reason, + change.replaced_by, + &request.id.to_hex(), + ) + .await? + } else { + unarchive_on(&mut tx, change.target).await? + }; + if changed { + Some( + crate::event::insert_event_in_transaction(&mut tx, delta, None) + .await? + .0, + ) + } else { + None + } + } else { + None + }; + tx.commit().await?; + Ok((stored, inserted, notification)) + } + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. #[datastore_span(name = "is_archived", system = "postgresql")] pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 919c1467b69..e87b4b77d1c 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -1,22 +1,21 @@ //! NIP-IA identity archive request handler (kinds 9035–9036). //! -//! These events are processed before storage: the request mutates the -//! `archived_identities` table and may emit relay-signed NIP-IA deltas and a -//! snapshot, then the ingest pipeline stores the request itself for audit. +//! Validate consent before storage. Persist the request, archive mutation and +//! relay delta atomically; publish only after commit. use std::sync::Arc; use nostr::{Event, PublicKey}; -use tracing::{info, warn}; -use buzz_core::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_PROFILE}; +use buzz_core::kind::{ + KIND_IA_ARCHIVED, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVED, KIND_IA_UNARCHIVE_REQUEST, + KIND_PROFILE, +}; use buzz_core::tenant::TenantContext; -use buzz_core::CommunityId; +use buzz_core::{CommunityId, StoredEvent}; use buzz_db::EventQuery; -use crate::handlers::side_effects::{ - publish_nipia_archival_list, publish_nipia_archived, publish_nipia_unarchived, -}; +use crate::handlers::side_effects::{build_nipia_delta, publish_nipia_archival_list}; use crate::state::AppState; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -36,12 +35,20 @@ impl ConsentPath { } } -/// Validate and execute a NIP-IA archive/unarchive request. -pub async fn handle_identity_archive_event( +/// Validated archive parameters; no persistence occurs during validation. +pub(super) struct IdentityArchivePlan { + target: String, + consent: ConsentPath, + reason: Option, + replaced_by: Option, +} + +/// Validate a NIP-IA archive/unarchive request without changing state. +pub(super) async fn validate_identity_archive_event( tenant: &TenantContext, state: &Arc, event: &Event, -) -> Result<(), String> { +) -> Result { let kind = event.kind.as_u16() as u32; let actor_hex = event.pubkey.to_hex(); @@ -64,77 +71,83 @@ pub async fn handle_identity_archive_event( let reason = extract_tag_value(event, "reason"); let consent_path = determine_consent_path(tenant.community(), state, event, &target_hex, &actor_hex).await?; - let request_event_id = event.id.to_hex(); + Ok(IdentityArchivePlan { + target: target_hex, + consent: consent_path, + reason, + replaced_by, + }) +} - let changed = if kind == KIND_IA_ARCHIVE_REQUEST { - state - .db - .archive( - tenant.community(), - &target_hex, - consent_path.as_str(), - &actor_hex, - reason.as_deref(), - replaced_by.as_deref(), - &request_event_id, - ) - .await - .map_err(|e| format!("database error: {e}"))? +/// Commit archive state and its audit events before publishing notifications. +pub(super) async fn persist_identity_archive_event( + tenant: &TenantContext, + state: &Arc, + event: &Event, + plan: IdentityArchivePlan, +) -> anyhow::Result<(StoredEvent, bool)> { + let archive = u32::from(event.kind.as_u16()) == KIND_IA_ARCHIVE_REQUEST; + let delta_kind = if archive { + KIND_IA_ARCHIVED } else { - state - .db - .unarchive(tenant.community(), &target_hex) - .await - .map_err(|e| format!("database error: {e}"))? + KIND_IA_UNARCHIVED }; - - info!( - actor = %actor_hex, - target = %target_hex, - consent = consent_path.as_str(), - changed, - kind, - "identity archive request processed" - ); - - if !changed { - return Ok(()); - } - - let publish_delta = if kind == KIND_IA_ARCHIVE_REQUEST { - publish_nipia_archived( - tenant, - state, - &target_hex, - consent_path.as_str(), - &actor_hex, - &request_event_id, - &event.content, - reason.as_deref(), - replaced_by.as_deref(), + // Use the request timestamp for its corresponding relay notification. + let delta = build_nipia_delta( + state, + delta_kind, + &plan.target, + plan.consent.as_str(), + &event.pubkey.to_hex(), + &event.id.to_hex(), + &event.content, + event.created_at, + plan.reason.as_deref(), + plan.replaced_by.as_deref(), + )?; + let (stored, inserted, notification) = state + .db + .apply_identity_archive_event( + tenant.community(), + event, + buzz_db::archived_identities::IdentityArchiveChange { + target: &plan.target, + archive, + consent_path: plan.consent.as_str(), + reason: plan.reason.as_deref(), + replaced_by: plan.replaced_by.as_deref(), + }, + &delta, ) - .await - } else { - publish_nipia_unarchived( + .await?; + if let Some(notification) = notification { + super::event::dispatch_persistent_event( tenant, state, - &target_hex, - consent_path.as_str(), - &actor_hex, - &request_event_id, - &event.content, - reason.as_deref(), + ¬ification, + delta_kind, + &state.relay_keypair.public_key().to_hex(), + None, ) - .await - }; - - if let Err(e) = publish_delta { - warn!(error = %e, "failed to publish NIP-IA delta"); - } - if let Err(e) = publish_nipia_archival_list(tenant, state).await { - warn!(error = %e, "failed to publish NIP-IA archival list"); + .await; } + // Also refresh on duplicate retries: a previous post-commit refresh may + // have failed. Propagate failures so callers can retry without reapplying + // the archive mutation. The stored delta remains available for replay. + publish_nipia_archival_list(tenant, state).await?; + Ok((stored, inserted)) +} +#[cfg(test)] +async fn handle_identity_archive_event( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result<(), String> { + let plan = validate_identity_archive_event(tenant, state, event).await?; + persist_identity_archive_event(tenant, state, event, plan) + .await + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9e6988737e1..e53c302ca0d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3082,15 +3082,19 @@ async fn ingest_event_inner( None }; - // Processed here (verify consent, mutate archived_identities, emit the - // relay-signed 8002/8003 delta + 13535 snapshot), then — unlike the - // 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) + // Consent validation has no side effects. Persistence below commits the + // request, archive mutation and relay delta in one guarded transaction. + let identity_archive = if is_identity_archive_request_kind(kind_u32) { + Some( + crate::handlers::identity_archive::validate_identity_archive_event( + tenant, state, &event, + ) .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; - } + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?, + ) + } else { + None + }; if kind_u32 == KIND_DELETION { // The target's archived channel blocks the deletion, so it must @@ -3601,6 +3605,18 @@ async fn ingest_event_inner( }); } } + } else if let Some(plan) = identity_archive { + crate::handlers::identity_archive::persist_identity_archive_event( + tenant, state, &event, plan, + ) + .await + .map_err(|e| { + e.downcast_ref::() + .and_then(event_write_error) + .unwrap_or_else(|| { + IngestError::Internal(format!("error: archive write failed: {e}")) + }) + })? } else if workflow_deletion { // A single commit owns public acceptance, domain mutation, and dispatch. // Failure rolls everything back; identical concurrent requests cannot diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index add1ddcd9b2..ef16e7f2178 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -8,10 +8,10 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_u32, is_parameterized_replaceable, AUTHOR_ONLY_KINDS, KIND_AGENT_PROFILE, - KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, - KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, - KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, + KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED_LIST, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, + KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, + KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -4135,8 +4135,7 @@ pub async fn publish_dm_visibility_snapshot( } #[allow(clippy::too_many_arguments)] -async fn publish_nipia_delta( - tenant: &TenantContext, +pub(super) fn build_nipia_delta( state: &Arc, kind: u32, target_pubkey_hex: &str, @@ -4144,11 +4143,10 @@ async fn publish_nipia_delta( actor_pubkey_hex: &str, request_event_id: &str, content: &str, + created_at: nostr::Timestamp, reason: Option<&str>, replaced_by: Option<&str>, -) -> anyhow::Result<()> { - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); - +) -> anyhow::Result { let mut tags = vec![ Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?, Tag::parse(["p", target_pubkey_hex]) @@ -4174,82 +4172,12 @@ async fn publish_nipia_delta( let event = EventBuilder::new(Kind::Custom(kind as u16), content) .tags(tags) + .allow_self_tagging() + .custom_created_at(created_at) .sign_with_keys(&state.relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; - let (stored, was_inserted) = state - .db - .insert_event(tenant.community(), &event, None) - .await?; - if !was_inserted { - return Ok(()); - } - - dispatch_persistent_event(tenant, state, &stored, kind, &relay_pubkey_hex, None).await; - - info!( - target = %target_pubkey_hex, - relay = %relay_pubkey_hex, - kind, - consent = %consent_path, - "NIP-IA delta event published" - ); - Ok(()) -} - -/// Publish a kind:8002 archived-identity delta event (NIP-IA). -#[allow(clippy::too_many_arguments)] -pub async fn publish_nipia_archived( - tenant: &TenantContext, - state: &Arc, - target_pubkey_hex: &str, - consent_path: &str, - actor_pubkey_hex: &str, - request_event_id: &str, - content: &str, - reason: Option<&str>, - replaced_by: Option<&str>, -) -> anyhow::Result<()> { - publish_nipia_delta( - tenant, - state, - KIND_IA_ARCHIVED, - target_pubkey_hex, - consent_path, - actor_pubkey_hex, - request_event_id, - content, - reason, - replaced_by, - ) - .await -} - -/// Publish a kind:8003 unarchived-identity delta event (NIP-IA). -#[allow(clippy::too_many_arguments)] -pub async fn publish_nipia_unarchived( - tenant: &TenantContext, - state: &Arc, - target_pubkey_hex: &str, - consent_path: &str, - actor_pubkey_hex: &str, - request_event_id: &str, - content: &str, - reason: Option<&str>, -) -> anyhow::Result<()> { - publish_nipia_delta( - tenant, - state, - KIND_IA_UNARCHIVED, - target_pubkey_hex, - consent_path, - actor_pubkey_hex, - request_event_id, - content, - reason, - None, - ) - .await + Ok(event) } #[cfg(test)] diff --git a/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs new file mode 100644 index 00000000000..8a0872015e8 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs @@ -0,0 +1,285 @@ +use super::*; + +fn archive_request(key: &Keys, kind: u16) -> Event { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .allow_self_tagging() + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &key.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(key) + .unwrap() +} + +async fn archive_snapshot(f: &Fixture) -> Vec { + let mut snapshot = Vec::new(); + for table in ["archived_identities", "events"] { + let rows: Value = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT coalesce(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) FROM {table} t WHERE community_id=$1"))) + .bind(f.tenant.community().as_uuid()).fetch_one(&f.pool).await.unwrap(); + snapshot.push(rows); + } + snapshot +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_and_unarchive_refusals_leave_no_state_or_notification_events() { + for websocket in [false, true] { + for kind in [9035, 9036] { + for actor in [0, 1, 2] { + let f = Fixture::new().await; + let key = match actor { + 0 => &f.user, + 1 => &f.agent, + _ => &f.peer, + }; + // Before fencing, both operations must work through the real path. + let archive = archive_request(key, 9035); + let unarchive = archive_request(key, 9036); + for event in [&archive, &unarchive] { + let (accepted, reason) = f.send(key, event, websocket).await; + assert!(accepted, "baseline: {reason}"); + } + if kind == 9036 { + assert!(f.send(key, &archive_request(key, 9035), websocket).await.0); + } + f.fence().await; + let before = archive_snapshot(&f).await; + let event = archive_request(key, kind); + let (accepted, reason) = f.send(key, &event, websocket).await; + if actor == 2 { + assert!(accepted, "unaffected user: {reason}"); + assert_eq!( + f.state + .db + .is_archived(f.tenant.community(), &key.public_key().to_hex()) + .await + .unwrap(), + kind == 9035 + ); + } else { + assert!(!accepted); + assert_eq!( + reason, + "restricted: identity is restricted for user deletion" + ); + assert_eq!( + archive_snapshot(&f).await, + before, + "refused kind {kind} changed archive state or persisted notifications" + ); + } + } + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_delta_failure_rolls_back_request_and_mutation() { + let f = Fixture::new().await; + sqlx::raw_sql( + "CREATE FUNCTION fail_archive_delta() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'injected archive delta failure'; END $$; + CREATE TRIGGER fail_archive_delta BEFORE INSERT ON events + FOR EACH ROW WHEN (NEW.kind IN (8002, 8003)) EXECUTE FUNCTION fail_archive_delta();", + ) + .execute(&f.pool) + .await + .unwrap(); + for websocket in [false, true] { + for kind in [9035, 9036] { + if kind == 9036 { + f.state + .db + .archive( + f.tenant.community(), + &f.user.public_key().to_hex(), + "self", + &f.user.public_key().to_hex(), + None, + None, + &"a".repeat(64), + ) + .await + .unwrap(); + } + let before = archive_snapshot(&f).await; + let request = archive_request(&f.user, kind); + let (accepted, _, status) = f.submit(&f.user, &request, websocket).await; + assert!(!accepted, "injected database failure must reach the caller"); + if let Some(status) = status { + assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + assert_eq!( + archive_snapshot(&f).await, + before, + "delta failure must roll back the request and archive change" + ); + f.state + .db + .unarchive(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap(); + } + } + sqlx::raw_sql("DROP TRIGGER fail_archive_delta ON events; DROP FUNCTION fail_archive_delta();") + .execute(&f.pool) + .await + .unwrap(); + assert!( + f.send(&f.user, &archive_request(&f.user, 9035), false) + .await + .0 + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_snapshot_failure_is_retryable_without_reapplying_mutation() { + for websocket in [false, true] { + let f = Fixture::new().await; + let archive = archive_request(&f.user, 9035); + sqlx::raw_sql( + "CREATE FUNCTION fail_archive_snapshot() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'injected archive snapshot failure'; END $$; + CREATE TRIGGER fail_archive_snapshot BEFORE INSERT ON events + FOR EACH ROW WHEN (NEW.kind = 13535) EXECUTE FUNCTION fail_archive_snapshot();", + ) + .execute(&f.pool) + .await + .unwrap(); + let (accepted, _, status) = f.submit(&f.user, &archive, websocket).await; + assert!(!accepted); + if let Some(status) = status { + assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + assert!(f + .state + .db + .is_archived(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap()); + assert!(f + .state + .db + .get_event_by_id(f.tenant.community(), archive.id.as_bytes()) + .await + .unwrap() + .is_some()); + sqlx::raw_sql( + "DROP TRIGGER fail_archive_snapshot ON events; DROP FUNCTION fail_archive_snapshot();", + ) + .execute(&f.pool) + .await + .unwrap(); + assert!( + f.send(&f.user, &archive, websocket).await.0, + "duplicate retry refreshes snapshot" + ); + let unarchive = archive_request(&f.user, 9036); + assert!(f.send(&f.user, &unarchive, websocket).await.0); + assert!(f.send(&f.user, &archive, websocket).await.0); + assert!( + !f.state + .db + .is_archived(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap(), + "replaying old archive must not undo later unarchive" + ); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind IN (8002,8003)", + ) + .bind(f.tenant.community().as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(count, 2, "retries must not create new deltas"); + assert!( + f.send(&f.user, &archive_request(&f.user, 9035), websocket) + .await + .0 + ); + assert!(f.send(&f.user, &unarchive, websocket).await.0); + assert!( + f.state + .db + .is_archived(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap(), + "replaying old unarchive must not undo later archive" + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_requests_wait_for_fence_commit_or_rollback() { + for websocket in [false, true] { + for kind in [9035, 9036] { + for commit in [false, true] { + let f = Arc::new(Fixture::new().await); + if kind == 9036 { + assert!( + f.send(&f.user, &archive_request(&f.user, 9035), websocket) + .await + .0 + ); + } + let before = archive_snapshot(&f).await; + let mut gate = f.pending_fence().await; + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *gate) + .await + .unwrap(); + let task_f = f.clone(); + let task = tokio::spawn(async move { + task_f + .send( + &task_f.user, + &archive_request(&task_f.user, kind), + websocket, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))") + .bind(pid).fetch_one(&f.pool).await.unwrap(); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("archive writer must wait for the exclusive identity lock"); + assert_eq!( + archive_snapshot(&f).await, + before, + "no partial state before fencing decides" + ); + if commit { + gate.commit().await.unwrap(); + } else { + gate.rollback().await.unwrap(); + } + let (accepted, reason) = tokio::time::timeout(Duration::from_secs(8), task) + .await + .unwrap() + .unwrap(); + assert_eq!(accepted, !commit, "{reason}"); + if commit { + assert_eq!(archive_snapshot(&f).await, before); + } else { + assert_eq!( + f.state + .db + .is_archived(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap(), + kind == 9035 + ); + } + } + } + } +} diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs index e9b36952b05..8dab00c9fd1 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -261,3 +261,6 @@ mod expanded_postgres_tests; #[path = "user_deletion_error_postgres_tests.rs"] mod error_postgres_tests; + +#[path = "user_deletion_archive_postgres_tests.rs"] +mod archive_postgres_tests; From 21f8dc627dd2170ae517c85369b9bc7000b58681 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:36:43 -0400 Subject: [PATCH 08/11] Document serving lock order and verify artifact fence races Signed-off-by: Ravneet Arora --- .../src/store/artifact_postgres_tests.rs | 180 ++++++++++++++++++ .../buzz-db/src/store/user_deletion_fences.rs | 27 ++- 2 files changed, 200 insertions(+), 7 deletions(-) diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 97534792e76..378a68a5386 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -698,3 +698,183 @@ async fn head_outside_authorized_channel_matches_missing_head() { ArtifactOutcome::Accepted(_) )); } + +/// Exercise the production Move transaction, including its second, relay-signed +/// insert after domain row writes. Exclusive holders do not touch domain rows. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_move_and_identity_fences_finish_without_partial_writes_or_deadlocks() { + use crate::user_deletion_fences::{self as fences, FenceIdentity}; + use std::{sync::Arc, time::Duration}; + for block_relay in [false, true] { + let f = Arc::new(Fixture::new().await); + let d = Uuid::new_v4(); + let create = f.revision(d, "create", f.a, None, &f.owner, vec![]); + f.accept(&create, None).await; + let moved = f.revision(d, "move", f.b, Some(&create), &f.owner, vec![]); + let move_id = moved.id; + let store = f.db.entity_deletion_store(); + let request = + f.db.user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", Duration::from_secs(300)) + .await + .unwrap() + .unwrap(); + let identity = FenceIdentity::user(f.owner.public_key()); + let mut gate = f.db.pool.begin().await.unwrap(); + if block_relay { + fences::lock_exclusive(&mut gate, &[FenceIdentity::user(f.relay.public_key())]) + .await + .unwrap(); + } else { + fences::insert(&mut gate, identity, &lease, None) + .await + .unwrap(); + } + let gate_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *gate) + .await + .unwrap(); + let task_f = f.clone(); + let task = tokio::spawn(async move { + let env = artifact::validate(&moved).unwrap(); + task_f + .db + .accept_artifact( + task_f.community, + &moved, + &env, + Some(task_f.a), + &task_f.relay, + ) + .await + }); + wait_for_artifact_blocker(&f.db.pool, gate_pid).await; + let writer_pid: i32 = sqlx::query_scalar("SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)) LIMIT 1") + .bind(gate_pid).fetch_one(&f.db.pool).await.unwrap(); + let coordinate_held: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE pid=$1 AND locktype='advisory' AND granted AND mode='ExclusiveLock' + AND classid=((hashtextextended($2,0) >> 32) & 4294967295)::oid AND objid=(hashtextextended($2,0) & 4294967295)::oid)") + .bind(writer_pid).bind(format!("artifact:{}:{}", f.community.as_uuid(), d)).fetch_one(&f.db.pool).await.unwrap(); + assert!( + coordinate_held, + "actual artifact caller must hold its domain lock before waiting" + ); + let pending_user_fence = if block_relay { + let mut user_gate = f.db.pool.begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *user_gate) + .await + .unwrap(); + let fence_task = tokio::spawn(async move { + fences::insert(&mut user_gate, identity, &lease, None) + .await + .unwrap(); + user_gate.commit().await.unwrap(); + }); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let blockers: Vec = sqlx::query_scalar("SELECT pg_blocking_pids($1)") + .bind(pid) + .fetch_one(&f.db.pool) + .await + .unwrap(); + if blockers.contains(&writer_pid) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("fence must wait on Move's already acquired author lock"); + Some(fence_task) + } else { + None + }; + gate.commit().await.unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(8), task) + .await + .unwrap() + .unwrap(); + if let Some(task) = pending_user_fence { + tokio::time::timeout(Duration::from_secs(8), task) + .await + .unwrap() + .unwrap(); + } + if block_relay { + let ArtifactOutcome::Accepted(events) = outcome.unwrap() else { + panic!("Move should finish before queued author fence"); + }; + assert_eq!(events.len(), 2); + assert_eq!(events[1].event.kind.as_u16(), 45011); + } else { + assert!(matches!( + outcome, + Err(crate::DbError::UserDeletionRestricted) + )); + } + let head: Vec = sqlx::query_scalar( + "SELECT event_id FROM artifact_heads WHERE community_id=$1 AND artifact_id=$2", + ) + .bind(f.community.as_uuid()) + .bind(d) + .fetch_one(&f.db.pool) + .await + .unwrap(); + assert_eq!( + head, + if block_relay { + move_id.as_bytes() + } else { + create.id.as_bytes() + } + ); + let revision_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM artifact_revisions WHERE community_id=$1 AND event_id=$2)", + ) + .bind(f.community.as_uuid()) + .bind(move_id.as_bytes().as_slice()) + .fetch_one(&f.db.pool) + .await + .unwrap(); + assert_eq!(revision_exists, block_relay); + let removals: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND kind=45011") + .bind(f.community.as_uuid()) + .fetch_one(&f.db.pool) + .await + .unwrap(); + assert_eq!(removals, i64::from(block_relay)); + let deadlocks: i64 = sqlx::query_scalar( + "SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()", + ) + .fetch_one(&f.db.pool) + .await + .unwrap(); + assert_eq!(deadlocks, 0); + } +} + +async fn wait_for_artifact_blocker(pool: &PgPool, pid: i32) { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))") + .bind(pid).fetch_one(pool).await.unwrap(); + if waiting { return; } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }).await.expect("artifact writer must wait on identity lock"); +} diff --git a/crates/buzz-db/src/store/user_deletion_fences.rs b/crates/buzz-db/src/store/user_deletion_fences.rs index 3b39ca593a1..04521be01d6 100644 --- a/crates/buzz-db/src/store/user_deletion_fences.rs +++ b/crates/buzz-db/src/store/user_deletion_fences.rs @@ -1,13 +1,26 @@ //! Operator-global restriction storage and identity locks. Shared event insertion //! paths consult these restrictions; the real user-deletion worker is not integrated. //! -//! Callers use a writer-pool READ COMMITTED transaction. Acquire existing -//! community guards first, then `lock_request` for the worker, then all human -//! locks, then scoped agent locks. -//! Within each group acquire the whole set in one call (sorted here), never -//! upgrade shared locks to exclusive. Gate workers may discover agents after -//! locking humans, then lock the complete agent set. Keep the transaction open -//! through the protected write/checkpoint; commit or rollback releases the locks. +//! Callers use writer-pool READ COMMITTED transactions. Community admission +//! precedes identity locking. Each lock call sorts global keys before scoped +//! keys, but this is NOT a transaction-wide order for serving writes: artifact +//! and channel writers can already hold domain row/advisory locks, and a later +//! relay-signed event can acquire another global identity lock in the same tx. +//! +//! Exclusive fencing transactions must lock the request first, acquire their +//! complete global identity set before their complete scoped set, then persist +//! only restriction/request/checkpoint state. They must not lock or mutate +//! users, channels, artifacts, events, or other serving-domain rows while +//! holding exclusive identity locks. Perform cleanup in later transactions. +//! Otherwise a writer holding a domain lock and waiting for a shared identity +//! lock can cycle with the fencing transaction. Ownership discovery under the +//! fence uses ordinary reads, not FOR UPDATE or owner mutation. +//! +//! Never upgrade shared identity locks to exclusive. Before enabling a worker, +//! audit its complete exclusive sets against multi-author event transactions; +//! per-call sorting alone does not prove arbitrary multi-key batches deadlock +//! free. Keep locks through the protected write/checkpoint; commit or rollback +//! releases them. The real deletion worker remains disabled. use buzz_core::CommunityId; use chrono::{DateTime, Utc}; From b76879cb77d76eede1aa557494597c04d10fce5f Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:42:16 -0400 Subject: [PATCH 09/11] Register fencing regressions directly with shared fixtures Signed-off-by: Ravneet Arora --- crates/buzz-db/src/store/mod.rs | 5 + ...user_deletion_lock_order_postgres_tests.rs | 7 +- .../src/store/user_deletion_test_support.rs | 119 +++++++++ ...ser_deletion_write_guard_postgres_tests.rs | 123 +-------- crates/buzz-relay/src/handlers/mod.rs | 11 + .../user_deletion_archive_postgres_tests.rs | 6 +- .../user_deletion_error_postgres_tests.rs | 6 +- .../user_deletion_expanded_postgres_tests.rs | 44 +--- .../user_deletion_lifecycle_postgres_tests.rs | 4 +- .../user_deletion_message_postgres_tests.rs | 218 +-------------- .../handlers/user_deletion_test_support.rs | 249 ++++++++++++++++++ 11 files changed, 412 insertions(+), 380 deletions(-) create mode 100644 crates/buzz-db/src/store/user_deletion_test_support.rs create mode 100644 crates/buzz-relay/src/handlers/user_deletion_test_support.rs diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index c6e4df718f7..cf43ae7168d 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -101,3 +101,8 @@ mod unlisted_channels_postgres_tests; #[cfg(test)] mod admission_postgres_tests; + +#[cfg(test)] +mod user_deletion_lock_order_postgres_tests; +#[cfg(test)] +mod user_deletion_test_support; diff --git a/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs index 806c4e1eeb2..462bac5df6f 100644 --- a/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_lock_order_postgres_tests.rs @@ -1,4 +1,9 @@ -use super::*; +use super::user_deletion_fences::{self as fences, FenceIdentity}; +use super::user_deletion_test_support::{message, restricted, wait_for_waiter, Fixture}; +use crate::{event, Db, DbError}; +use nostr::Keys; +use std::time::Duration; +use uuid::Uuid; /// Exercise the actual TTL-before-author settings writer against a message /// holding the author's shared lock and a queued exclusive fence. PostgreSQL diff --git a/crates/buzz-db/src/store/user_deletion_test_support.rs b/crates/buzz-db/src/store/user_deletion_test_support.rs new file mode 100644 index 00000000000..624081f2b68 --- /dev/null +++ b/crates/buzz-db/src/store/user_deletion_test_support.rs @@ -0,0 +1,119 @@ +use super::user_deletion_fences::{self as fences, FenceIdentity}; +use crate::{Db, DbError}; +use buzz_core::CommunityId; +use nostr::{Event, EventBuilder, Keys, Kind}; +use sqlx::{PgPool, Postgres, Transaction}; +use std::time::Duration; +use uuid::Uuid; + +pub(super) struct Fixture { + pub(super) pool: PgPool, + pub(super) db: Db, + pub(super) community: CommunityId, + pub(super) lease: crate::entity_deletion::EntityLease, +} + +impl Fixture { + pub(super) async fn new() -> Self { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let db = Db::from_pool(pool.clone()); + let community = db + .ensure_configured_community(&format!("message-fences-{}.test", Uuid::new_v4())) + .await + .unwrap() + .id; + let request = db + .user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", Duration::from_secs(300)) + .await + .unwrap() + .unwrap(); + Self { + pool, + db, + community, + lease, + } + } + + pub(super) async fn fence(&self, keys: &Keys, owner: Option<&Keys>) { + let mut tx = self.pool.begin().await.unwrap(); + self.fence_in(&mut tx, keys, owner).await; + tx.commit().await.unwrap(); + } + + pub(super) async fn fence_in( + &self, + tx: &mut Transaction<'_, Postgres>, + keys: &Keys, + owner: Option<&Keys>, + ) { + let identity = if owner.is_some() { + FenceIdentity::agent(self.community, keys.public_key()) + } else { + FenceIdentity::user(keys.public_key()) + }; + fences::insert(tx, identity, &self.lease, owner.map(Keys::public_key)) + .await + .unwrap(); + } + + pub(super) async fn agent(&self, owner: &Keys) -> Keys { + self.db + .ensure_user(self.community, owner.public_key().as_bytes()) + .await + .unwrap(); + let agent = Keys::generate(); + self.db + .ensure_user(self.community, agent.public_key().as_bytes()) + .await + .unwrap(); + self.db + .set_agent_owner( + self.community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + agent + } +} + +pub(super) fn message(keys: &Keys, kind: u16) -> Event { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .sign_with_keys(keys) + .unwrap() +} + +pub(super) fn restricted(result: crate::Result<(buzz_core::StoredEvent, bool)>) { + assert!( + matches!(result, Err(DbError::UserDeletionRestricted)), + "{result:?}" + ); +} + +pub(super) async fn wait_for_waiter(pool: &PgPool, pid: i32) { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let waiting: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_locks WHERE pid = $1 AND locktype = 'advisory' AND NOT granted)").bind(pid).fetch_one(pool).await.unwrap(); + if waiting { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("production writer must wait for the identity lock"); +} diff --git a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs index ac9e849e1e4..7597fe5da75 100644 --- a/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs +++ b/crates/buzz-db/src/store/user_deletion_write_guard_postgres_tests.rs @@ -1,123 +1,9 @@ use super::user_deletion_fences::{self as fences, FenceIdentity}; -use crate::{event, Db, DbError}; -use buzz_core::CommunityId; -use nostr::{Event, EventBuilder, Keys, Kind}; -use sqlx::{PgPool, Postgres, Transaction}; +use super::user_deletion_test_support::{message, restricted, wait_for_waiter, Fixture}; +use crate::{event, DbError}; +use nostr::{Event, Keys}; use std::time::Duration; use uuid::Uuid; - -struct Fixture { - pool: PgPool, - db: Db, - community: CommunityId, - lease: crate::entity_deletion::EntityLease, -} - -impl Fixture { - async fn new() -> Self { - let pool = PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - let db = Db::from_pool(pool.clone()); - let community = db - .ensure_configured_community(&format!("message-fences-{}.test", Uuid::new_v4())) - .await - .unwrap() - .id; - let request = db - .user_deletion_store() - .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) - .await - .unwrap(); - let store = db.entity_deletion_store(); - store - .approve( - request.id, - "reviewer", - request.inventory_digest.as_deref().unwrap(), - ) - .await - .unwrap(); - let lease = store - .claim(request.id, "worker", Duration::from_secs(300)) - .await - .unwrap() - .unwrap(); - Self { - pool, - db, - community, - lease, - } - } - - async fn fence(&self, keys: &Keys, owner: Option<&Keys>) { - let mut tx = self.pool.begin().await.unwrap(); - self.fence_in(&mut tx, keys, owner).await; - tx.commit().await.unwrap(); - } - - async fn fence_in( - &self, - tx: &mut Transaction<'_, Postgres>, - keys: &Keys, - owner: Option<&Keys>, - ) { - let identity = if owner.is_some() { - FenceIdentity::agent(self.community, keys.public_key()) - } else { - FenceIdentity::user(keys.public_key()) - }; - fences::insert(tx, identity, &self.lease, owner.map(Keys::public_key)) - .await - .unwrap(); - } - - async fn agent(&self, owner: &Keys) -> Keys { - self.db - .ensure_user(self.community, owner.public_key().as_bytes()) - .await - .unwrap(); - let agent = Keys::generate(); - self.db - .ensure_user(self.community, agent.public_key().as_bytes()) - .await - .unwrap(); - self.db - .set_agent_owner( - self.community, - agent.public_key().as_bytes(), - owner.public_key().as_bytes(), - ) - .await - .unwrap(); - agent - } -} - -fn message(keys: &Keys, kind: u16) -> Event { - EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) - .sign_with_keys(keys) - .unwrap() -} - -fn restricted(result: crate::Result<(buzz_core::StoredEvent, bool)>) { - assert!( - matches!(result, Err(DbError::UserDeletionRestricted)), - "{result:?}" - ); -} - -async fn wait_for_waiter(pool: &PgPool, pid: i32) { - tokio::time::timeout(Duration::from_secs(4), async { - loop { - let waiting: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_locks WHERE pid = $1 AND locktype = 'advisory' AND NOT granted)").bind(pid).fetch_one(pool).await.unwrap(); - if waiting { break; } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }).await.expect("production writer must wait for the identity lock"); -} - async fn writer( f: &Fixture, event: Event, @@ -376,6 +262,3 @@ async fn ownership_change_while_waiting_rejects_and_retry_uses_current_owner() { .await, ); } - -#[path = "user_deletion_lock_order_postgres_tests.rs"] -mod lock_order_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index a887bd60dba..348cfecb0af 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -85,3 +85,14 @@ pub fn resolve_ttl(event: &nostr::Event, ephemeral_ttl_override: Option) -> /// NIP-AR artifact lifecycle. pub mod artifact; + +#[cfg(test)] +mod user_deletion_archive_postgres_tests; +#[cfg(test)] +mod user_deletion_error_postgres_tests; +#[cfg(test)] +mod user_deletion_expanded_postgres_tests; +#[cfg(test)] +mod user_deletion_lifecycle_postgres_tests; +#[cfg(test)] +mod user_deletion_test_support; diff --git a/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs index 8a0872015e8..2568c8f449a 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs @@ -1,4 +1,8 @@ -use super::*; +use super::user_deletion_test_support::Fixture; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::Value; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; fn archive_request(key: &Keys, kind: u16) -> Event { EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) diff --git a/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs index 39b8aa67c20..d7210dbe555 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_error_postgres_tests.rs @@ -1,4 +1,8 @@ -use super::*; +use super::group_state_postgres_tests::create_channel; +use super::user_deletion_test_support::Fixture; +use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; +use nostr::{EventBuilder, Kind, Tag}; +use std::{sync::Arc, time::Duration}; #[test] fn identity_error_mapping_preserves_unrelated_failures() { diff --git a/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs index e01989d9ef0..24d7ac8b2fd 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_expanded_postgres_tests.rs @@ -1,43 +1,9 @@ //! Persistent insertion coverage through HTTP and WebSocket event handling. -use super::*; - -fn action(key: &Keys, channel: Uuid, kind: u16, content: &str, extra: Vec>) -> Event { - let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; - tags.extend(extra.into_iter().map(|tag| Tag::parse(tag).unwrap())); - EventBuilder::new(Kind::Custom(kind), content) - .tags(tags) - .sign_with_keys(key) - .unwrap() -} - -async fn snapshot(f: &Fixture) -> Vec { - let mut rows = Vec::new(); - for table in [ - "events", - "channels", - "channel_members", - "reactions", - "thread_metadata", - "event_mentions", - "operator_listener_outbox", - "artifact_heads", - "artifact_revisions", - "workflows", - ] { - let query = format!("SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) FROM {table} t WHERE community_id=$1"); - rows.push( - sqlx::query_scalar(sqlx::AssertSqlSafe(query)) - .bind(f.tenant.community().as_uuid()) - .fetch_one(&f.pool) - .await - .unwrap(), - ); - } - rows -} - -#[path = "user_deletion_lifecycle_postgres_tests.rs"] -mod lifecycle_postgres_tests; +use super::group_state_postgres_tests::create_channel; +use super::user_deletion_test_support::{action, snapshot, Fixture}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; /// Each case must succeed before fencing, then leave every relevant table /// unchanged on rejection. Both real HTTP routing and the WS EVENT handler run. diff --git a/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs index 5763b184864..139f5d80899 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_lifecycle_postgres_tests.rs @@ -1,4 +1,6 @@ -use super::*; +use super::group_state_postgres_tests::create_channel; +use super::user_deletion_test_support::{action, snapshot, Fixture}; +use uuid::Uuid; #[tokio::test] #[ignore = "requires Postgres"] diff --git a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs index 8dab00c9fd1..da4530e82c8 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_message_postgres_tests.rs @@ -1,211 +1,4 @@ -//! Exercise both transport entry points and the real message transaction. - -use std::{sync::Arc, time::Duration}; - -use buzz_core::TenantContext; -use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; -use nostr::{Event, EventBuilder, Keys, Kind, Tag}; -use serde_json::Value; -use uuid::Uuid; - -use super::group_state_postgres_tests::{community, create_channel, test_conn}; -use crate::state::AppState; - -struct Fixture { - state: Arc, - pool: sqlx::PgPool, - tenant: TenantContext, - channel: Uuid, - user: Keys, - agent: Keys, - peer: Keys, - url: String, - server: tokio::task::JoinHandle<()>, -} - -impl Fixture { - async fn new() -> Self { - let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() - .await - .expect("Postgres and Redis"); - let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) - .await - .unwrap(); - let tenant = community(&state, "message-deletion").await; - let (user, agent, peer) = (Keys::generate(), Keys::generate(), Keys::generate()); - state - .db - .ensure_user(tenant.community(), user.public_key().as_bytes()) - .await - .unwrap(); - state - .db - .ensure_user(tenant.community(), agent.public_key().as_bytes()) - .await - .unwrap(); - state - .db - .set_agent_owner( - tenant.community(), - agent.public_key().as_bytes(), - user.public_key().as_bytes(), - ) - .await - .unwrap(); - let channel = create_channel( - &state, - &tenant, - &peer, - &[ - &["p", &user.public_key().to_hex()], - &["p", &agent.public_key().to_hex()], - ], - ) - .await; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let router = crate::router::build_router(state.clone()); - let server = tokio::spawn(async move { - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await - .unwrap(); - }); - Self { - url, - server, - state, - pool, - tenant, - channel, - user, - agent, - peer, - } - } - - fn message(&self, key: &Keys, kind: u16, root: Option<&Event>) -> Event { - let mut tags = vec![Tag::parse(["h", &self.channel.to_string()]).unwrap()]; - if let Some(root) = root { - tags.push(Tag::parse(["e", &root.id.to_hex(), "", "root"]).unwrap()); - tags.push(Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()); - } - EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) - .tags(tags) - .sign_with_keys(key) - .unwrap() - } - - async fn fence(&self) { - self.pending_fence().await.commit().await.unwrap(); - } - - async fn pending_fence(&self) -> sqlx::Transaction<'static, sqlx::Postgres> { - let request = self - .state - .db - .user_deletion_store() - .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) - .await - .unwrap(); - let store = self.state.db.entity_deletion_store(); - store - .approve( - request.id, - "reviewer", - request.inventory_digest.as_deref().unwrap(), - ) - .await - .unwrap(); - let lease = store - .claim(request.id, "worker", Duration::from_secs(300)) - .await - .unwrap() - .unwrap(); - let mut tx = self.pool.begin().await.unwrap(); - let user = FenceIdentity::user(self.user.public_key()); - let agent = FenceIdentity::agent(self.tenant.community(), self.agent.public_key()); - fences::lock_request(&mut tx, &lease).await.unwrap(); - fences::lock_exclusive(&mut tx, &[user, agent]) - .await - .unwrap(); - fences::insert(&mut tx, user, &lease, None).await.unwrap(); - fences::insert(&mut tx, agent, &lease, Some(self.user.public_key())) - .await - .unwrap(); - tx - } - - async fn send(&self, key: &Keys, event: &Event, websocket: bool) -> (bool, String) { - let (accepted, message, status) = self.submit(key, event, websocket).await; - if let Some(status) = status.filter(|s| !s.is_success()) { - assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{message}"); - } - (accepted, message) - } - - async fn submit( - &self, - key: &Keys, - event: &Event, - websocket: bool, - ) -> (bool, String, Option) { - if websocket { - let (conn, mut rx) = test_conn(&self.tenant, key); - if let crate::connection::AuthState::Authenticated(ctx) = - &mut *conn.auth_state.lock().unwrap() - { - ctx.scopes = buzz_auth::Scope::all_known(); - // Intentionally no cached owner: the write must use PostgreSQL. - ctx.agent_owner_pubkey = None; - } - super::event::handle_event(event.clone(), conn, self.state.clone()).await; - let axum::extract::ws::Message::Text(text) = rx.try_recv().unwrap() else { - panic!("expected OK"); - }; - let frame: Value = serde_json::from_str(&text).unwrap(); - assert_eq!(frame[0], "OK", "{frame}"); - assert_eq!(frame[1], event.id.to_hex()); - ( - frame[2].as_bool().unwrap(), - frame[3].as_str().unwrap().to_string(), - None, - ) - } else { - let response = reqwest::Client::new() - .post(format!("{}/events", self.url)) - .header("host", self.tenant.host()) - .header("x-pubkey", key.public_key().to_hex()) - .json(event) - .send() - .await - .unwrap(); - let status = response.status(); - let body: Value = response.json().await.unwrap(); - if status.is_success() { - ( - body["accepted"].as_bool().unwrap(), - body["message"].as_str().unwrap_or("").to_owned(), - Some(status), - ) - } else { - ( - false, - body["error"].as_str().unwrap().to_owned(), - Some(status), - ) - } - } - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - self.server.abort(); - } -} +use super::user_deletion_test_support::Fixture; #[tokio::test] #[ignore = "requires Postgres"] @@ -255,12 +48,3 @@ async fn http_and_websocket_messages_honor_user_and_agent_fences() { ); } } - -#[path = "user_deletion_expanded_postgres_tests.rs"] -mod expanded_postgres_tests; - -#[path = "user_deletion_error_postgres_tests.rs"] -mod error_postgres_tests; - -#[path = "user_deletion_archive_postgres_tests.rs"] -mod archive_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/user_deletion_test_support.rs b/crates/buzz-relay/src/handlers/user_deletion_test_support.rs new file mode 100644 index 00000000000..170d29f8775 --- /dev/null +++ b/crates/buzz-relay/src/handlers/user_deletion_test_support.rs @@ -0,0 +1,249 @@ +//! Shared fixtures for user-deletion transport regressions. + +use std::{sync::Arc, time::Duration}; + +use buzz_core::TenantContext; +use buzz_db::user_deletion_fences::{self as fences, FenceIdentity}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::Value; +use uuid::Uuid; + +use super::group_state_postgres_tests::{community, create_channel, test_conn}; +use crate::state::AppState; + +pub(super) struct Fixture { + pub(super) state: Arc, + pub(super) pool: sqlx::PgPool, + pub(super) tenant: TenantContext, + pub(super) channel: Uuid, + pub(super) user: Keys, + pub(super) agent: Keys, + pub(super) peer: Keys, + url: String, + server: tokio::task::JoinHandle<()>, +} + +impl Fixture { + pub(super) async fn new() -> Self { + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("Postgres and Redis"); + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let tenant = community(&state, "message-deletion").await; + let (user, agent, peer) = (Keys::generate(), Keys::generate(), Keys::generate()); + state + .db + .ensure_user(tenant.community(), user.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .ensure_user(tenant.community(), agent.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .set_agent_owner( + tenant.community(), + agent.public_key().as_bytes(), + user.public_key().as_bytes(), + ) + .await + .unwrap(); + let channel = create_channel( + &state, + &tenant, + &peer, + &[ + &["p", &user.public_key().to_hex()], + &["p", &agent.public_key().to_hex()], + ], + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let router = crate::router::build_router(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + Self { + url, + server, + state, + pool, + tenant, + channel, + user, + agent, + peer, + } + } + + pub(super) fn message(&self, key: &Keys, kind: u16, root: Option<&Event>) -> Event { + let mut tags = vec![Tag::parse(["h", &self.channel.to_string()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", &root.id.to_hex(), "", "root"]).unwrap()); + tags.push(Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap()); + } + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(key) + .unwrap() + } + + pub(super) async fn fence(&self) { + self.pending_fence().await.commit().await.unwrap(); + } + + pub(super) async fn pending_fence(&self) -> sqlx::Transaction<'static, sqlx::Postgres> { + let request = self + .state + .db + .user_deletion_store() + .submit(&format!("demo:{}", Uuid::new_v4()), "operator", None) + .await + .unwrap(); + let store = self.state.db.entity_deletion_store(); + store + .approve( + request.id, + "reviewer", + request.inventory_digest.as_deref().unwrap(), + ) + .await + .unwrap(); + let lease = store + .claim(request.id, "worker", Duration::from_secs(300)) + .await + .unwrap() + .unwrap(); + let mut tx = self.pool.begin().await.unwrap(); + let user = FenceIdentity::user(self.user.public_key()); + let agent = FenceIdentity::agent(self.tenant.community(), self.agent.public_key()); + fences::lock_request(&mut tx, &lease).await.unwrap(); + fences::lock_exclusive(&mut tx, &[user, agent]) + .await + .unwrap(); + fences::insert(&mut tx, user, &lease, None).await.unwrap(); + fences::insert(&mut tx, agent, &lease, Some(self.user.public_key())) + .await + .unwrap(); + tx + } + + pub(super) async fn send(&self, key: &Keys, event: &Event, websocket: bool) -> (bool, String) { + let (accepted, message, status) = self.submit(key, event, websocket).await; + if let Some(status) = status.filter(|s| !s.is_success()) { + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{message}"); + } + (accepted, message) + } + + pub(super) async fn submit( + &self, + key: &Keys, + event: &Event, + websocket: bool, + ) -> (bool, String, Option) { + if websocket { + let (conn, mut rx) = test_conn(&self.tenant, key); + if let crate::connection::AuthState::Authenticated(ctx) = + &mut *conn.auth_state.lock().unwrap() + { + ctx.scopes = buzz_auth::Scope::all_known(); + // Intentionally no cached owner: the write must use PostgreSQL. + ctx.agent_owner_pubkey = None; + } + super::event::handle_event(event.clone(), conn, self.state.clone()).await; + let axum::extract::ws::Message::Text(text) = rx.try_recv().unwrap() else { + panic!("expected OK"); + }; + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "OK", "{frame}"); + assert_eq!(frame[1], event.id.to_hex()); + ( + frame[2].as_bool().unwrap(), + frame[3].as_str().unwrap().to_string(), + None, + ) + } else { + let response = reqwest::Client::new() + .post(format!("{}/events", self.url)) + .header("host", self.tenant.host()) + .header("x-pubkey", key.public_key().to_hex()) + .json(event) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + if status.is_success() { + ( + body["accepted"].as_bool().unwrap(), + body["message"].as_str().unwrap_or("").to_owned(), + Some(status), + ) + } else { + ( + false, + body["error"].as_str().unwrap().to_owned(), + Some(status), + ) + } + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +pub(super) fn action( + key: &Keys, + channel: Uuid, + kind: u16, + content: &str, + extra: Vec>, +) -> Event { + let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; + tags.extend(extra.into_iter().map(|tag| Tag::parse(tag).unwrap())); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(key) + .unwrap() +} + +pub(super) async fn snapshot(f: &Fixture) -> Vec { + let mut rows = Vec::new(); + for table in [ + "events", + "channels", + "channel_members", + "reactions", + "thread_metadata", + "event_mentions", + "operator_listener_outbox", + "artifact_heads", + "artifact_revisions", + "workflows", + ] { + let query = format!("SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) FROM {table} t WHERE community_id=$1"); + rows.push( + sqlx::query_scalar(sqlx::AssertSqlSafe(query)) + .bind(f.tenant.community().as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(), + ); + } + rows +} From 4aef33b95454052e3a59f6799c322001f6b8614b Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 14:42:40 -0400 Subject: [PATCH 10/11] Describe event fencing rules and remaining coordination work Signed-off-by: Ravneet Arora --- docs/user-deletion-message-fencing.md | 111 +++++++++++++++++++++----- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md index 30a8d582c4e..9d5047b10b6 100644 --- a/docs/user-deletion-message-fencing.md +++ b/docs/user-deletion-message-fencing.md @@ -1,39 +1,112 @@ # User-deletion fencing: event database writes -This PR checks existing restrictions in the two shared event insertion functions in `buzz-db/src/store/event.rs`: `insert_event_on` and `insert_event_with_thread_metadata_tx`. Every event kind reaching those functions is checked. HTTP and WebSocket event handlers report the restriction as a client refusal. +The two shared event insertion functions in `crates/buzz-db/src/store/event.rs`, +`insert_event_on` and `insert_event_with_thread_metadata_tx`, refuse events +whose signer has a user-deletion restriction. Every event kind reaching these +functions is checked, including relay-signed events. ## Transaction flow -1. Enter the existing community-admitted database transaction. -2. Read the event author's stored owner in this community, if present. -3. Acquire shared global author/owner identity locks and the scoped author lock in canonical order. -4. Read the owner again. If it changed while acquiring locks, reject for retry in a new transaction. -5. Read the author's global and community-scoped restrictions. Reject a restricted author. -6. Persist the event and its transactional derived changes. Commit or rollback releases the locks. +1. Enter a community-admitted, writer-pool READ COMMITTED transaction. +2. Read the signer's stored owner in this community, if present. +3. Acquire shared global signer/owner locks and the scoped signer lock. Each + call sorts its global keys before its scoped keys. +4. Read the owner again. If it changed while acquiring locks, return + `EventAuthorOwnershipChanged`; the caller must roll back and retry in a new + transaction. +5. Query the signer's global and community-scoped restrictions together. If + either exists, return `UserDeletionRestricted` and roll back. +6. Persist the event and its transactional derived changes. Hold the locks + until commit or rollback. -Human restrictions are global by public key; agent restrictions are community-scoped. The author's own restriction decides whether the event is rejected. An owner's restriction is not an additional authorization check for an independently admitted agent. Restrictions still apply when profile rows are absent or recreated. +Human restrictions apply across communities by public key. Agent restrictions +apply in their specified community. The signer's own restriction decides +whether the event is rejected; the owner's restriction is not an additional +check for an independently admitted agent. Missing or recreated profile rows do +not bypass restrictions. -The writer uses the existing identity lock keys used by restriction insertion. If an event transaction wins first, the fence waits for it to finish. If the fence commits first, a waiting event sees the restriction and is rejected. If the fence rolls back, the event may proceed. No user-specific lease is involved. +If a writer holds the shared identity lock first, an exclusive fence waits for +its transaction to finish. If the fence commits first, the waiting writer sees +the restriction and rolls back. If the fence rolls back, the writer may proceed. +No user-specific lease is involved in this database-write protocol. -## Scope +## Errors and atomicity -The guard covers persistence through these two functions, including messages, replies, reactions, edits, votes, canvas, channel changes, deletion events and artifacts. Tests exercise actual callers and check that rejection rolls back transactional domain rows and reply counters. +`event_write_error` maps `UserDeletionRestricted` to the client refusal +`restricted: identity is restricted for user deletion`. Ownership changes use +the retryable internal-error category: HTTP responds with 500; WebSocket +responds with `error: internal server error`. This classification permits a +retry; it does not implement client retries. Unrelated database errors retain +the caller's existing mapping rather than becoming restriction errors. -Separate replaceable-event writers (profiles, emoji collections, bookmarks and workflow definitions), personal read state, uploads, git, audio and ephemeral activity are outside this PR. Authentication, agent creation/owner assignment, affected-user checks and background work are also separate work. This is not complete user-activity fencing. +Archive/unarchive requests (9035/9036) validate consent without changing state. +Their persistence transaction stores the guarded request, changes +`archived_identities`, and stores the relay delta (8002/8003). A failure rolls +back all three. Notifications are dispatched after commit. Duplicate requests +do not reapply the archive change or create another delta. -There are no schema changes, lease changes, or changes to restriction insertion behavior. The existing restriction reader is exposed within the database module so event writers can read after acquiring the full ordered lock set. Tests install restrictions through the previously merged helper solely as fixture setup. +The archival snapshot (13535) is refreshed after commit. A refresh failure +propagates to the caller; retrying the request refreshes the snapshot without +repeating the mutation. The committed request and delta remain available for +query/replay. This is not a durable background retry queue: a process exit +between commit and snapshot refresh can leave the snapshot stale until a later +refresh. Existing freshness/consent validation still applies to retries. -The real user-deletion worker remains disabled. Before enabling it, ownership/admission writes and other serving paths must be coordinated, and the worker must atomically fence the user and the approved owned-agent set. The owner recheck here only detects changes observed while obtaining locks; it does not implement a complete ownership-assignment protocol. +## Actual lock order and exclusive-worker requirements -## Validation +Shared identity locking occurs at event insertion, not necessarily at the +start of the transaction. Artifact and channel writers can already hold domain +row/advisory locks. An artifact Move inserts the author's revision and then a +relay-signed removal, acquiring another global identity lock after the first +signer's scoped lock. Per-call sorting is not a transaction-wide lock order. -Regression tests cover global/scoped restrictions, unaffected users and communities, missing profiles, fence commit/rollback, writes already in flight, observed ownership changes, and the channel-TTL lock interleaving. Relay tests exercise signed HTTP requests and WebSocket EVENT handling before and after restrictions, including replies and unchanged database snapshots on refusal. +An exclusive fencing transaction must: + +- Acquire community admission, then the deletion request row, then its complete + global identity set, then its complete scoped identity set. +- Write only restriction/request/checkpoint state while holding those locks. + Do not lock or mutate users, channels, artifacts, events or other serving + domain rows. Read ownership without row locks and perform cleanup in later + transactions. +- Never upgrade a shared identity lock to exclusive. Audit complete exclusive + sets against transactions containing multiple signers before wiring a worker; + per-call sorting alone does not prove arbitrary multi-key batches safe. + +The domain-row restriction prevents a fence from waiting on a row held by a +writer that is itself waiting on the fence. PostgreSQL regressions exercise +channel-TTL and artifact Move interleavings and assert that no deadlock was +recorded. Those cases are evidence for the tested paths, not a proof about every +future worker or lock combination. + +## Coverage and remaining work + +Persistence through the two guarded functions includes messages, replies, +reactions, edits, votes, canvas, channel changes, deletion events, artifacts, +and identity archive/unarchive requests. A refusal rolls back domain changes +performed in the same transaction, including counters and notification events. + +Separate replaceable-event writers (profiles, emoji collections, bookmarks and +workflow definitions), personal read state, uploads, git, audio and ephemeral +activity are outside this guard. Authentication, agent creation/owner +assignment, checks on affected users other than the signer, and background work +need separate coordination. + +The real user-deletion worker remains disabled. Before enabling it, coordinate +all remaining serving paths and external work, and atomically fence the user +and the approved owned-agent set. The owner reread detects changes observed +while obtaining locks; it is not a complete ownership-assignment protocol. +Restriction insertion behavior and the schema are unchanged by event guards. + +## Regression tests + +The directly registered `user_deletion_*_postgres_tests` modules in `buzz-db` +and `buzz-relay` cover scopes, missing profiles, unaffected actors, commit and +rollback races, ownership changes, transport errors and transactional state. +The artifact fence race lives in `artifact_postgres_tests`. With isolated PostgreSQL and Redis configured: ```sh scripts/postgres-test-run.sh -p buzz-db -p buzz-relay --lib \ - -E 'test(/user_deletion_write_guard|user_deletion_message/)' --test-threads 4 + -E 'test(/user_deletion/) or test(/artifact_move_and_identity/)' --test-threads 4 ``` - -The earlier 510-case live matrix validated a broader revision that included leases. It is not evidence of this narrowed revision's scope or coverage. Current validation results belong in the PR description. From 3564dc2dcdaec4c9007b95114d07fa21960f3d41 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Fri, 30 Oct 2026 16:46:33 -0400 Subject: [PATCH 11/11] Preserve archive snapshots on duplicate requests Signed-off-by: Ravneet Arora --- .../buzz-relay/src/handlers/side_effects.rs | 27 ++++ .../user_deletion_archive_postgres_tests.rs | 122 +++++++++++++++++- docs/user-deletion-message-fencing.md | 4 +- 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 414afe44206..55cacfd0674 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -4010,6 +4010,33 @@ pub async fn publish_nipia_archival_list( ..buzz_db::event::EventQuery::for_community(tenant.community()) }) .await?; + // A retry may need to repair a failed publication, but a current + // snapshot must retain its id/timestamp and emit no new notification. + // Tag order is not meaningful for the protected set of archived keys. + if previous.first().is_some_and(|snapshot| { + snapshot.event.content.is_empty() + && snapshot + .event + .tags + .iter() + .map(Tag::as_slice) + .collect::>() + == tags.iter().map(Tag::as_slice).collect() + }) { + // The canonical read can become stale before we inspect the head. + // Keep the same drift protection as the publication path below. + let current_archived = state.db.list_archived(tenant.community()).await?; + if archived + .iter() + .map(|identity| identity.pubkey.as_str()) + .eq(current_archived + .iter() + .map(|identity| identity.pubkey.as_str())) + { + return Ok(()); + } + continue; + } let created_at = previous .first() .map(|event| (event.event.created_at.as_secs() + 1).max(now)) diff --git a/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs index 2568c8f449a..d3214563671 100644 --- a/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/user_deletion_archive_postgres_tests.rs @@ -79,6 +79,47 @@ async fn archive_and_unarchive_refusals_leave_no_state_or_notification_events() } } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_replays_preserve_snapshot_identity_and_timestamp() { + for websocket in [false, true] { + let f = Fixture::new().await; + let archive = archive_request(&f.user, 9035); + let unarchive = archive_request(&f.user, 9036); + for event in [&archive, &unarchive] { + assert!(f.send(&f.user, event, websocket).await.0); + let before = archive_snapshot(&f).await; + for _ in 0..2 { + assert!(f.send(&f.user, event, websocket).await.0); + assert_eq!( + archive_snapshot(&f).await, + before, + "duplicate must preserve every row, including snapshot id and created_at" + ); + } + } + let before = archive_snapshot(&f).await; + assert!(f.send(&f.user, &archive, websocket).await.0); + assert_eq!( + archive_snapshot(&f).await, + before, + "old archive replay must not replace the newer unarchive snapshot" + ); + assert!( + f.send(&f.user, &archive_request(&f.user, 9035), websocket) + .await + .0 + ); + let before = archive_snapshot(&f).await; + assert!(f.send(&f.user, &unarchive, websocket).await.0); + assert_eq!( + archive_snapshot(&f).await, + before, + "old unarchive replay must not replace the newer archive snapshot" + ); + } +} + #[tokio::test] #[ignore = "requires Postgres"] async fn archive_delta_failure_rolls_back_request_and_mutation() { @@ -142,8 +183,15 @@ async fn archive_delta_failure_rolls_back_request_and_mutation() { #[tokio::test] #[ignore = "requires Postgres"] async fn archive_snapshot_failure_is_retryable_without_reapplying_mutation() { - for websocket in [false, true] { + for (websocket, seed_snapshot) in [(false, false), (false, true), (true, false), (true, true)] { let f = Fixture::new().await; + if seed_snapshot { + assert!( + f.send(&f.peer, &archive_request(&f.peer, 9035), websocket) + .await + .0 + ); + } let archive = archive_request(&f.user, 9035); sqlx::raw_sql( "CREATE FUNCTION fail_archive_snapshot() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -182,6 +230,26 @@ async fn archive_snapshot_failure_is_retryable_without_reapplying_mutation() { f.send(&f.user, &archive, websocket).await.0, "duplicate retry refreshes snapshot" ); + let tags: Value = sqlx::query_scalar( + "SELECT tags FROM events WHERE community_id=$1 AND kind=13535 AND deleted_at IS NULL", + ) + .bind(f.tenant.community().as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert!( + tags.as_array() + .unwrap() + .contains(&serde_json::json!(["p", f.user.public_key().to_hex()])), + "retry must repair a missing or stale snapshot" + ); + let repaired = archive_snapshot(&f).await; + assert!(f.send(&f.user, &archive, websocket).await.0); + assert_eq!( + archive_snapshot(&f).await, + repaired, + "retry after repair must leave the snapshot unchanged" + ); let unarchive = archive_request(&f.user, 9036); assert!(f.send(&f.user, &unarchive, websocket).await.0); assert!(f.send(&f.user, &archive, websocket).await.0); @@ -200,7 +268,11 @@ async fn archive_snapshot_failure_is_retryable_without_reapplying_mutation() { .fetch_one(&f.pool) .await .unwrap(); - assert_eq!(count, 2, "retries must not create new deltas"); + assert_eq!( + count, + if seed_snapshot { 3 } else { 2 }, + "retries must not create new deltas" + ); assert!( f.send(&f.user, &archive_request(&f.user, 9035), websocket) .await @@ -218,6 +290,52 @@ async fn archive_snapshot_failure_is_retryable_without_reapplying_mutation() { } } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_snapshot_noop_rechecks_concurrent_state_changes() { + let f = Fixture::new().await; + assert!( + f.send(&f.user, &archive_request(&f.user, 9035), false) + .await + .0 + ); + let (reached, release) = super::side_effects::publish_test_hooks::arm(f.tenant.community()); + let state = f.state.clone(); + let tenant = f.tenant.clone(); + let publisher = tokio::spawn(async move { + super::side_effects::publish_nipia_archival_list(&tenant, &state).await + }); + tokio::time::timeout(Duration::from_secs(10), reached) + .await + .unwrap() + .unwrap(); + // Keep the old matching snapshot while changing canonical state. A stale + // no-op decision must not leave that snapshot in place. + f.state + .db + .unarchive(f.tenant.community(), &f.user.public_key().to_hex()) + .await + .unwrap(); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(10), publisher) + .await + .unwrap() + .unwrap() + .unwrap(); + let tags: Value = sqlx::query_scalar( + "SELECT tags FROM events WHERE community_id=$1 AND kind=13535 AND deleted_at IS NULL", + ) + .bind(f.tenant.community().as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + tags, + serde_json::json!([["-"]]), + "publisher must repair the snapshot after detecting canonical drift" + ); +} + #[tokio::test] #[ignore = "requires Postgres"] async fn archive_requests_wait_for_fence_commit_or_rollback() { diff --git a/docs/user-deletion-message-fencing.md b/docs/user-deletion-message-fencing.md index 9d5047b10b6..01f3d580781 100644 --- a/docs/user-deletion-message-fencing.md +++ b/docs/user-deletion-message-fencing.md @@ -45,7 +45,9 @@ Their persistence transaction stores the guarded request, changes back all three. Notifications are dispatched after commit. Duplicate requests do not reapply the archive change or create another delta. -The archival snapshot (13535) is refreshed after commit. A refresh failure +The archival snapshot (13535) is refreshed after commit only when missing or +stale. An already-current snapshot retains its event ID and timestamp and is +not published again, including on duplicate requests. A refresh failure propagates to the caller; retrying the request refreshes the snapshot without repeating the mutation. The committed request and delta remain available for query/replay. This is not a durable background retry queue: a process exit