Skip to content
Merged
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
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,17 @@ jobs:
--run-ignored ignored-only
env:
RELAY_URL: ws://localhost:3000
- name: NIP-MP coordinate deletion guard
# Verifies the never-delete-newer invariant of soft_delete_by_coordinate:
# a stale tombstone (created_at earlier than the live head) spares that
# head, and an equal-timestamp tombstone deletes it.
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down Expand Up @@ -739,7 +750,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632;
/// NIP-34: Status — Draft.
pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;

/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository
/// announcements (parameterized replaceable, d=project slug).
///
/// Members are `a` tags holding `30617:<owner-hex>:<repo-d>` coordinates, so one
/// project may span repositories owned by different pubkeys. The signer gains no
/// authority over any member: push policy reads the repository's own
/// announcement, never a project. See `docs/nips/NIP-MP.md`.
pub const KIND_PROJECT: u32 = 30621;

/// All registered kind constants — used for duplicate detection and iteration.
pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
Expand Down Expand Up @@ -739,6 +748,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_PROJECT,
];

/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
Expand Down Expand Up @@ -836,6 +846,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999

Expand Down
29 changes: 26 additions & 3 deletions crates/buzz-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,31 +782,54 @@ pub async fn soft_delete_event(
}

/// Soft-delete the live row for an addressable coordinate
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key.
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not
/// newer than the deletion request.
///
/// 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).
///
/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09
/// scopes an `a`-tag deletion to versions at or before that instant, so a
/// delayed or replayed tombstone signed between two versions must not erase the
/// newer replacement. `events.created_at` is immutable per row, so the predicate
/// guarantees a tombstone can never erase a version newer than itself — the UPDATE
/// re-evaluates its WHERE clause after any lock wait, so a replacement that races
/// the deletion and lands with a later `created_at` is always spared.
///
/// This does NOT guarantee deletion completeness when a same-coordinate
/// replacement races the deletion: the deletion may evaluate its predicate before
/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That
/// outcome is state-identical to the deletion having arrived first (old head
/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes
/// the order of concurrent writes from different signers, and even same-signer
/// ordering is advisory. The return value feeds only a debug log, not a
/// correctness gate.
///
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
/// (already deleted, or never existed).
/// (already deleted, never existed, or strictly newer than the deletion).
pub async fn soft_delete_by_coordinate(
pool: &PgPool,
community_id: CommunityId,
kind: i32,
pubkey: &[u8],
d_tag: &str,
deletion_created_at_secs: i64,
) -> Result<bool> {
let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0)
.ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?;
let result = sqlx::query(
"UPDATE events SET deleted_at = NOW() \
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL",
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \
AND created_at <= $5",
)
.bind(community_id.as_uuid())
.bind(kind)
.bind(pubkey)
.bind(d_tag)
.bind(deletion_created_at)
.execute(pool)
.await?;

Expand Down
86 changes: 83 additions & 3 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1813,16 +1813,27 @@ impl Db {
event::soft_delete_event(&self.pool, community_id, 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.
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`
/// when it is not newer than the deletion request.
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds;
/// `deletion_created_at_secs` is the deletion event's `created_at`.
pub async fn soft_delete_by_coordinate(
&self,
community_id: CommunityId,
kind: i32,
pubkey: &[u8],
d_tag: &str,
deletion_created_at_secs: i64,
) -> Result<bool> {
event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await
event::soft_delete_by_coordinate(
&self.pool,
community_id,
kind,
pubkey,
d_tag,
deletion_created_at_secs,
)
.await
}

/// Atomically soft-delete an event and decrement thread reply counters.
Expand Down Expand Up @@ -5227,6 +5238,75 @@ mod tests {
);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn coordinate_delete_spares_head_newer_than_the_deletion() {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};

let db = setup_db().await;
let community = CommunityId::from_uuid(make_community(&db.pool).await);
let keys = Keys::generate();
let kind = buzz_core::kind::KIND_PROJECT as i32;
let d_tag = "stale-tombstone-project";
let pubkey = keys.public_key().to_bytes().to_vec();
let base = Timestamp::now().as_secs();

let version = |content: &str, offset: u64| {
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
.tags(vec![Tag::parse(["d", d_tag]).expect("d tag")])
.custom_created_at(Timestamp::from(base + offset))
.sign_with_keys(&keys)
.expect("sign project version")
};

for (content, offset) in [("v1", 0), ("v2", 100)] {
assert!(
db.replace_parameterized_event(community, &version(content, offset), d_tag, None)
.await
.expect("store project version")
.1
);
}

// Tombstone timestamped between V1 and V2: it authorizes deleting V1,
// never the newer head that replaced it.
let stale_deleted = db
.soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64)
.await
.expect("stale coordinate delete");
assert!(
!stale_deleted,
"a tombstone older than the live head must delete nothing"
);

let live_content: Option<String> = sqlx::query_scalar(
"SELECT content FROM events \
WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL",
)
.bind(community.as_uuid())
.bind(kind)
.bind(&pubkey)
.bind(d_tag)
.fetch_optional(&db.pool)
.await
.expect("read live head");
assert_eq!(
live_content.as_deref(),
Some("v2"),
"the newer head must survive a stale tombstone"
);

// A tombstone at or after the head's own timestamp still deletes it.
let current_deleted = db
.soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64)
.await
.expect("current coordinate delete");
assert!(
current_deleted,
"a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)"
);
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() {
Expand Down
14 changes: 11 additions & 3 deletions crates/buzz-relay/src/api/git/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2795,10 +2795,18 @@ mod sec005_read_gate_tests {
);

let owner_pk = f.owner_keys.public_key().to_bytes().to_vec();
// Tombstone timestamped after the announcement, per NIP-09's
// at-or-before scoping in `soft_delete_by_coordinate`.
let deleted =
f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo)
.await
.expect("soft delete 30617");
f.db.soft_delete_by_coordinate(
f.community,
30617,
&owner_pk,
&f.repo,
chrono::Utc::now().timestamp() + 60,
)
.await
.expect("soft delete 30617");
assert!(deleted, "precondition: a live announcement row was deleted");

assert!(
Expand Down
Loading
Loading