diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 420481d..7be5316 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -627,6 +627,8 @@ impl Nip34WritePolicy { // Parse announcement to get repository details match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { + self.maybe_apply_runtime_delist_deletion(event).await; + // Try to create bare repository if it doesn't exist if let Err(e) = self .announcement_policy @@ -732,6 +734,8 @@ impl Nip34WritePolicy { // Parse announcement to get details for logging match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { + self.maybe_apply_runtime_delist_deletion(event).await; + // Phase 5 recovery trigger: any accepted announcement route // (including maintainer acceptance) can restore eligible // holding/archive state for this owner+identifier. @@ -776,6 +780,7 @@ impl Nip34WritePolicy { } } AnnouncementResult::Reject(reason) => { + self.maybe_apply_runtime_delist_deletion(event).await; tracing::warn!( "Rejected repository announcement {}: {}", event_id_str, @@ -786,6 +791,102 @@ impl Nip34WritePolicy { } } + /// Runtime de-list parity path for already-served repositories. + /// + /// When a newer replacement announcement for the same owner+identifier no + /// longer lists this relay's service, trigger the normal cascade + /// delete->holding/archive flow for the currently served announcement. + /// + /// This mirrors startup whitelist/blacklist parity behavior, but runs at + /// write time so service removals are enforced immediately. + async fn maybe_apply_runtime_delist_deletion(&self, incoming: &Event) { + let incoming_announcement = match RepositoryAnnouncement::from_event(incoming.clone()) { + Ok(announcement) => announcement, + Err(_) => return, + }; + + // This path is only for announcements that REMOVE this relay listing. + if incoming_announcement.lists_service(&self.ctx.domain) { + return; + } + + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(incoming.pubkey) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + incoming_announcement.identifier.clone(), + ); + + let current = match self.ctx.database.query(filter).await { + Ok(events) => events.into_iter().max_by_key(|event| event.created_at), + Err(e) => { + tracing::warn!( + owner = %incoming.pubkey.to_hex(), + identifier = %incoming_announcement.identifier, + error = %e, + "Runtime de-list parity: failed querying existing announcement" + ); + None + } + }; + + let Some(current) = current else { + return; + }; + + // Not newer than what we already serve -> no runtime parity action. + if incoming.created_at <= current.created_at { + return; + } + + let current_announcement = match RepositoryAnnouncement::from_event(current.clone()) { + Ok(announcement) => announcement, + Err(e) => { + tracing::warn!( + event_id = %current.id.to_hex(), + owner = %incoming.pubkey.to_hex(), + identifier = %incoming_announcement.identifier, + error = %e, + "Runtime de-list parity: failed parsing current announcement" + ); + return; + } + }; + + // Safety guard: only run cascade when the currently served announcement + // actually lists this relay. + if !current_announcement.lists_service(&self.ctx.domain) { + return; + } + + match self + .deletion_policy + .apply_whitelist_deletion_for_announcement(¤t) + .await + { + Ok(()) => { + tracing::info!( + owner = %incoming.pubkey.to_hex(), + identifier = %incoming_announcement.identifier, + replaced_announcement = %current.id.to_hex(), + replacement = %incoming.id.to_hex(), + "Runtime de-list parity: deleted previously served repository" + ); + } + Err(e) => { + tracing::error!( + owner = %incoming.pubkey.to_hex(), + identifier = %incoming_announcement.identifier, + replaced_announcement = %current.id.to_hex(), + replacement = %incoming.id.to_hex(), + error = %e, + "Runtime de-list parity: deletion flow failed" + ); + } + } + } + /// Handle repository state event /// /// # Arguments diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index b474fbb..d878cbf 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -76,6 +76,22 @@ impl AnnouncementPolicy { ); } + // Owner de-list replacement for an already served repo: + // do NOT allow maintainer-exception acceptance to bypass + // de-list enforcement. Let the caller treat this as a + // reject path so runtime deletion parity can run. + let lists_service = announcement.lists_service(&self.config.domain); + if !lists_service { + match self + .has_db_announcement(&event.pubkey, &announcement.identifier) + .await + { + Ok(true) => return AnnouncementResult::Reject(reason), + Ok(false) => {} + Err(_) => return AnnouncementResult::Reject(reason), + } + } + match self .is_maintainer_in_any_announcement( &announcement.identifier, diff --git a/tests/nip09_recovery.rs b/tests/nip09_recovery.rs index 0c012b3..8d9dd56 100644 --- a/tests/nip09_recovery.rs +++ b/tests/nip09_recovery.rs @@ -1002,6 +1002,90 @@ async fn reannouncement_restores_events_and_git_archive_happy_path() { relay.stop().await; } +#[tokio::test] +async fn replacement_announcement_delisting_this_relay_triggers_runtime_deletion_flow() { + let relay = TestRelay::start_with_lmdb().await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .expect("create client"); + + let (announcement, repo_id, state_event) = + publish_served_repo_with_state_event(&client, "runtime-delist").await; + let issue = client + .create_issue(&announcement, "runtime delist issue", "body", vec![]) + .expect("build issue"); + client.send_event(issue.clone()).await.expect("send issue"); + tokio::time::sleep(Duration::from_millis(600)).await; + + assert!(client + .is_event_on_relay(issue.id) + .await + .expect("query issue before delist")); + + let replacement_delist = EventBuilder::new(Kind::GitRepoAnnouncement, "delist relay") + .tags(vec![ + Tag::identifier(repo_id.clone()), + Tag::custom("name", vec![repo_id.clone()]), + Tag::custom( + "clone", + vec![format!( + "https://example.com/{}/{}.git", + client.public_key().to_hex(), + repo_id + )], + ), + Tag::custom("relays", vec!["wss://example.com".to_string()]), + ]) + .custom_created_at(Timestamp::from_secs(announcement.created_at.as_secs() + 30)) + .finalize(client.keys()) + .expect("build replacement delist announcement"); + + let rejection = client.send_event(replacement_delist.clone()).await; + assert!( + rejection.is_err(), + "replacement delist announcement should be rejected" + ); + tokio::time::sleep(Duration::from_millis(900)).await; + + assert!( + !client + .is_event_on_relay(announcement.id) + .await + .expect("query original announcement after delist"), + "runtime de-list should delete the previously served announcement" + ); + assert!( + !client + .is_event_on_relay(state_event.id) + .await + .expect("query state after runtime delist"), + "runtime de-list should cascade-delete repository state" + ); + assert!( + !client + .is_event_on_relay(issue.id) + .await + .expect("query issue after runtime delist"), + "runtime de-list should cascade-delete dependent events" + ); + + let store = open_holding(&relay).await; + assert!( + !store.metadata_for_event(&announcement.id).await.is_empty(), + "runtime de-list should archive announcement into holding metadata" + ); + assert!( + !store.metadata_for_event(&state_event.id).await.is_empty(), + "runtime de-list should archive state into holding metadata" + ); + assert!( + !store.metadata_for_event(&issue.id).await.is_empty(), + "runtime de-list should archive dependent events into holding metadata" + ); + + relay.stop().await; +} + #[tokio::test] async fn out_of_retention_reannouncement_behaves_as_fresh_new_repo() { let relay = TestRelay::start_with_lmdb().await;