Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/sprout-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,36 @@ pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result<bool> {
Ok(result.rows_affected() > 0)
}

/// Soft-delete the live row for an addressable coordinate
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key.
///
/// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any
/// parameterized-replaceable kind. The WHERE clause mirrors
/// `replace_parameterized_event` so the coordinate semantics stay consistent:
/// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global
/// per the spec — `channel_id` is stored for query scoping, not identity).
///
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
/// (already deleted, or never existed).
pub async fn soft_delete_by_coordinate(
pool: &PgPool,
kind: i32,
pubkey: &[u8],
d_tag: &str,
) -> Result<bool> {
let result = sqlx::query(
"UPDATE events SET deleted_at = NOW() \
WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL",
)
.bind(kind)
.bind(pubkey)
.bind(d_tag)
.execute(pool)
.await?;

Ok(result.rows_affected() > 0)
}

/// Atomically soft-delete an event and decrement thread reply counters.
///
/// Wraps the delete + counter update in a single transaction so a crash between
Expand Down
11 changes: 11 additions & 0 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,17 @@ impl Db {
event::soft_delete_event(&self.pool, event_id).await
}

/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`.
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds.
pub async fn soft_delete_by_coordinate(
&self,
kind: i32,
pubkey: &[u8],
d_tag: &str,
) -> Result<bool> {
event::soft_delete_by_coordinate(&self.pool, kind, pubkey, d_tag).await
}

/// Atomically soft-delete an event and decrement thread reply counters.
pub async fn soft_delete_event_and_update_thread(
&self,
Expand Down
50 changes: 46 additions & 4 deletions crates/sprout-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ use tracing::{info, warn};
use uuid::Uuid;

use sprout_core::kind::{
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, 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,
event_kind_u32, is_parameterized_replaceable, KIND_GIT_REPO_ANNOUNCEMENT,
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,
};
use sprout_db::channel::MemberRole;

Expand Down Expand Up @@ -1380,11 +1380,53 @@ async fn handle_a_tag_deletion(event: &Event, state: &Arc<AppState>) -> anyhow::
}
}
}
// Generic NIP-33 (parameterized-replaceable) soft-delete by coordinate.
//
// Listed after the workflow branch so workflow's bespoke deletion
// (which doesn't soft-delete the `events` row by design — that's a
// separate concern) takes precedence. For every other addressable
// kind, including kind:30023 (NIP-23 long-form), we soft-delete the
// 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(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"
);
}
}
_ => {
tracing::debug!(
kind = kind_num,
d_tag = d_tag,
"NIP-09 a-tag deletion for unhandled kind — no side effect"
"NIP-09 a-tag deletion for non-NIP-33 kind — no side effect"
);
}
}
Expand Down
86 changes: 86 additions & 0 deletions crates/sprout-test-client/tests/e2e_long_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,89 @@ async fn test_long_form_stale_write_rejected() {

client.disconnect().await.expect("disconnect");
}

/// NIP-09 a-tag deletion: a kind:5 deletion targeting the addressable
/// coordinate `30023:<pubkey>:<d-tag>` causes the live event row for that
/// coordinate to be soft-deleted, so subsequent REQs no longer return it.
///
/// Regression test for issue #714 — before the fix,
/// `handle_a_tag_deletion` only handled the workflow kind and silently
/// no-op'd for kind:30023.
#[tokio::test]
#[ignore]
async fn test_long_form_a_tag_deletion() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");

// Publish a note.
let d_tag = format!("a-del-{}", uuid::Uuid::new_v4().simple());
let note = build_long_form_event(&keys, &d_tag, "Doomed Article", "Body.", vec![]);
let note_id = note.id;
let ok = client.send_event(note).await.expect("send note");
assert!(ok.accepted, "note should be accepted: {}", ok.message);

// Sanity check it's queryable before deletion.
let sid_pre = sub_id("a-del-pre");
let filter_pre = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.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 == note_id),
"note should be queryable before deletion"
);

// Build the addressable coordinate and emit a kind:5 deletion targeting it.
let a_coord = format!(
"{}:{}:{}",
KIND_LONG_FORM,
keys.public_key().to_hex(),
d_tag
);
let del = EventBuilder::new(
Kind::EventDeletion,
"",
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
);

// Query — should now be empty.
let sid_post = sub_id("a-del-post");
let filter_post = Filter::new()
.kind(Kind::Custom(KIND_LONG_FORM))
.author(keys.public_key())
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.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(),
"a-tag deletion should remove the note from REQ results (got {} events)",
post.len()
);

client.disconnect().await.expect("disconnect");
}
Loading