fix(announcements): runtime deletion on service delist replacements

This commit is contained in:
DanConwayDev
2026-06-19 09:06:57 +00:00
parent fba89ba702
commit d4b562c413
3 changed files with 201 additions and 0 deletions
+101
View File
@@ -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(&current)
.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
+16
View File
@@ -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,
+84
View File
@@ -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;