diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 65d04ef0ba..586cc654a3 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2071,6 +2071,50 @@ async fn handle_leave_request( // handle_reaction() removed — kind:7 reaction dedup and DB writes are now // handled inline in ingest_event() before storage (see ingest.rs step 20a). +/// Soft-delete the live `events` row for an addressable coordinate so REQs stop +/// returning it. +/// +/// Shared by the workflow branch and the generic NIP-33 branch of +/// [`handle_a_tag_deletion`]. Ownership of the coordinate is already enforced by +/// [`validate_standard_deletion_event`] before the event is stored, so this +/// helper does not re-check it. +async fn soft_delete_addressable_coordinate( + tenant: &TenantContext, + state: &Arc, + kind: u32, + pubkey_hex: &str, + d_tag: &str, +) -> anyhow::Result<()> { + let pubkey_bytes = hex::decode(pubkey_hex) + .map_err(|e| anyhow::anyhow!("invalid pubkey hex in a-tag {pubkey_hex}: {e}"))?; + // Safe cast: NIP-33 kinds are 30000-39999, well within i32 + let kind_i32 = kind as i32; + let deleted = state + .db + .soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to soft-delete by coordinate {kind_i32}:{pubkey_hex}:{d_tag}: {e}" + ) + })?; + + if deleted { + tracing::info!( + kind, + d_tag, + "NIP-09 a-tag deletion: soft-deleted addressable event by coordinate" + ); + } else { + tracing::debug!( + kind, + d_tag, + "NIP-09 a-tag deletion: no live row matched coordinate" + ); + } + Ok(()) +} + /// Handle NIP-09 deletion via `a` tag (addressable/parameterized-replaceable events). /// Parses "kind:pubkey:d-tag" and deletes the corresponding DB record. async fn handle_a_tag_deletion( @@ -2147,6 +2191,18 @@ async fn handle_a_tag_deletion( } } } + + // The DB row and the engine cache are gone; the kind:30620 definition + // event must follow, or `workflows list` / `workflows get` keep + // returning a workflow that `workflows trigger` rejects (#2879). + soft_delete_addressable_coordinate( + tenant, + state, + buzz_core::kind::KIND_WORKFLOW_DEF, + pubkey_hex, + d_tag, + ) + .await?; } // Generic NIP-33 (parameterized-replaceable) soft-delete by coordinate. // @@ -2157,38 +2213,7 @@ async fn handle_a_tag_deletion( // live row matching `(kind, pubkey, d_tag)` so REQs stop returning it. // See https://github.com/block/sprout/issues/714. k if is_parameterized_replaceable(k) => { - let pubkey_bytes = match hex::decode(pubkey_hex) { - Ok(b) => b, - Err(e) => { - return Err(anyhow::anyhow!( - "invalid pubkey hex in a-tag {pubkey_hex}: {e}" - )); - } - }; - // Safe cast: NIP-33 kinds are 30000–39999, well within i32. - let kind_i32 = k as i32; - let deleted = state - .db - .soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag) - .await - .map_err(|e| { - anyhow::anyhow!( - "failed to soft-delete by coordinate {kind_i32}:{pubkey_hex}:{d_tag}: {e}" - ) - })?; - if deleted { - tracing::info!( - kind = k, - d_tag = d_tag, - "NIP-09 a-tag deletion: soft-deleted addressable event by coordinate" - ); - } else { - tracing::debug!( - kind = k, - d_tag = d_tag, - "NIP-09 a-tag deletion: no live row matched coordinate" - ); - } + soft_delete_addressable_coordinate(tenant, state, k, pubkey_hex, d_tag).await?; } _ => { tracing::debug!( diff --git a/crates/buzz-test-client/tests/e2e_workflow_deletion.rs b/crates/buzz-test-client/tests/e2e_workflow_deletion.rs new file mode 100644 index 0000000000..55d342fc3e --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_workflow_deletion.rs @@ -0,0 +1,164 @@ +//! End-to-end regression test for NIP-09 a-tag deletion of workflow +//! definitions (kind:30620). +//! +//! Requires a running relay. Marked `#[ignore]` like the other e2e suites. +//! +//! ```text +//! cargo test --test e2e_workflow_deletion -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; + +const KIND_WORKFLOW_DEF: u16 = 30620; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn sub_id(name: &str) -> String { + format!("e2e-{name}-{}", uuid::Uuid::new_v4()) +} + +/// Create a real channel via REST so the relay accepts kind:30620 +async fn create_test_channel(keys: &Keys) -> String { + let client = reqwest::Client::new(); + let channel_uuid = uuid::Uuid::new_v4(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("wf-del-e2e-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-channel event"); + assert!( + resp.status().is_success(), + "channel creation failed: {}", + resp.status() + ); + channel_uuid.to_string() +} + +/// Deleting a workflow must also retire its kind:30620 definition event. +/// +/// Regression guard for #2879: the relay used to drop the DB row and invalidate +/// the engine cache while leaving the definition event live, so `workflows list` +/// and `workflows get`kept returning a workflow ` workflows trigger` rejected. +#[tokio::test] +#[ignore] +async fn test_workflow_delete_retires_definition_event() { + let url = relay_url(); + let keys = Keys::generate(); + let channel_id = create_test_channel(&keys).await; + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + // Publish a workflow + let workflow_id = uuid::Uuid::new_v4().to_string(); + let yaml = concat!( + "name: E2E Delete Probe\n", + "trigger:\n on: webhook\n", + "steps:\n", + " - id: noop\n action: send_message\n text: probe\n channel: general\n", + ); + let def = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF), yaml) + .tags(vec![ + Tag::parse(["d", &workflow_id]).unwrap(), + Tag::parse(["h", &channel_id]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let def_id = def.id; + let ok = client.send_event(def).await.expect("send workflow def"); + assert!( + ok.accepted, + "workflow def should be accepted:{}", + ok.message + ); + + // Sanity check it is queryable before deletion + let sid_pre = sub_id("wf-del-pre"); + let filter_pre = Filter::new() + .kind(Kind::Custom(KIND_WORKFLOW_DEF)) + .author(keys.public_key()) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + workflow_id.as_str(), + ); + client + .subscribe(&sid_pre, vec![filter_pre]) + .await + .expect("subscribe pre"); + let pre = client + .collect_until_eose(&sid_pre, Duration::from_secs(5)) + .await + .expect("collect pre"); + assert!( + pre.iter().any(|e| e.id == def_id), + "workflow definition should be queryable before deletion" + ); + + // Delete via the same a-tag coordinate `buzz workflows delete` emits. + let a_coord = format!( + "{}:{}:{}", + KIND_WORKFLOW_DEF, + keys.public_key().to_hex(), + workflow_id + ); + let del = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![Tag::parse(["a", &a_coord]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ok_del = client.send_event(del).await.expect("send deletion"); + assert!( + ok_del.accepted, + "a-tag deletion should be accepted: {}", + ok_del.message + ); + + // The definition must no longer surface - this is what `workflows list` + // and `workflows get` read. + let sid_post = sub_id("wf-del-post"); + let filter_post = Filter::new() + .kind(Kind::Custom(KIND_WORKFLOW_DEF)) + .author(keys.public_key()) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + workflow_id.as_str(), + ); + client + .subscribe(&sid_post, vec![filter_post]) + .await + .expect("subscribe post"); + let post = client + .collect_until_eose(&sid_post, Duration::from_secs(5)) + .await + .expect("collect post"); + assert!( + post.is_empty(), + "workflow deletion should retire the definition event (got {} events)", + post.len() + ); + + client.disconnect().await.expect("disconnect"); +}