mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
feat: admit NIP-62 request lifecycle
This commit is contained in:
@@ -10,12 +10,11 @@ and purgatory transitions.
|
||||
|
||||
## Bounded request retention
|
||||
|
||||
> **Status:** Lifecycle metadata, NIP-09 lifecycle admission (including
|
||||
> disrespector read-only would-have-deleted classification), deterministic
|
||||
> admission-use attribution, and measured destructive outcomes are implemented.
|
||||
> NIP-62 lifecycle admission, request cleanup/expiry, migration, target-set
|
||||
> deduplication removal, and remaining policy-mode reconciliation remain future
|
||||
> stages.
|
||||
> **Status:** Lifecycle metadata, NIP-09 and NIP-62 lifecycle admission
|
||||
> (including disrespector read-only would-have-deleted classification),
|
||||
> deterministic admission-use attribution, and measured destructive outcomes are
|
||||
> implemented. Request cleanup/expiry, migration, target-set deduplication
|
||||
> removal, and final policy-mode reconciliation remain future stages.
|
||||
|
||||
### Production motivation
|
||||
|
||||
|
||||
@@ -23,6 +23,46 @@ struct DeletedAnnouncementRepoScope {
|
||||
}
|
||||
|
||||
impl DeletionPolicy {
|
||||
/// Read-only normal-policy evaluation for a targeting NIP-62 request in
|
||||
/// disrespector mode. This deliberately does not invoke archive, cascade,
|
||||
/// holding, rollback, repository, or purgatory mutation helpers.
|
||||
pub(super) async fn would_nip62_vanish_stored_data(
|
||||
&self,
|
||||
event: &Event,
|
||||
) -> anyhow::Result<bool> {
|
||||
if !self
|
||||
.ctx
|
||||
.database
|
||||
.query(Filter::new().author(event.pubkey))
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let author = event.pubkey;
|
||||
if self
|
||||
.ctx
|
||||
.purgatory
|
||||
.announcements_for_sync()
|
||||
.into_iter()
|
||||
.any(|(repository_id, _)| {
|
||||
let parts: Vec<_> = repository_id.splitn(3, ':').collect();
|
||||
parts.len() == 3 && parts[1] == author.to_hex()
|
||||
})
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.ctx
|
||||
.purgatory
|
||||
.get_all_identifiers()
|
||||
.into_iter()
|
||||
.flat_map(|identifier| self.ctx.purgatory.find_state(&identifier))
|
||||
.any(|entry| entry.author == author))
|
||||
}
|
||||
|
||||
/// Move all currently served data authored by a targeted NIP-62 vanish
|
||||
/// request through the same holding/archive deletion lifecycle used for
|
||||
/// NIP-09 announcement deletion.
|
||||
|
||||
@@ -5,7 +5,7 @@ use nostr_relay_builder::prelude::{
|
||||
};
|
||||
|
||||
use crate::nostr::events::RepositoryAnnouncement;
|
||||
use crate::nostr::lifecycle::RequestLifecycleRecord;
|
||||
use crate::nostr::lifecycle::{RequestClassification, RequestLifecycleRecord};
|
||||
use crate::nostr::policy::{reject_error, reject_invalid, AnnouncementResult};
|
||||
|
||||
use super::{DeletionContext, DeletionOutcome, DeletionPolicy};
|
||||
@@ -287,22 +287,34 @@ impl DeletionService {
|
||||
}
|
||||
|
||||
pub async fn handle_vanish(&self, event: &Event) -> WritePolicyResult {
|
||||
// Archival mode: store the vanish request but do not process it.
|
||||
if self.ctx.config.deletion_request_disrespector {
|
||||
tracing::info!(
|
||||
event_id = %event.id.to_hex(),
|
||||
author = %event.pubkey.to_hex(),
|
||||
"Disrespector mode: storing NIP-62 vanish request without acting on it"
|
||||
);
|
||||
return WritePolicyResult::Accept;
|
||||
}
|
||||
|
||||
// Only honour requests that target this relay (or all relays). `domain`
|
||||
// is configured as an authority for GRASP matching, so derive both
|
||||
// `wss://` and `ws://` relay URL candidates when no scheme is present.
|
||||
// We still accept (store) well-formed kind-62 requests that target other
|
||||
// relays so clients get an OK for their event.
|
||||
let targets_relay = self.vanish_targets_this_relay(event);
|
||||
let classification = if !targets_relay {
|
||||
RequestClassification::NonTargetingNip62
|
||||
} else if self.ctx.config.deletion_request_disrespector {
|
||||
RequestClassification::Disrespector
|
||||
} else {
|
||||
RequestClassification::LocallyActionable
|
||||
};
|
||||
|
||||
// Persist every accepted NIP-62 request before accepting it or doing
|
||||
// destructive work. `record_request` preserves receipt time and
|
||||
// classification on an exact replay.
|
||||
if let Err(error) = self
|
||||
.ctx
|
||||
.tombstones()
|
||||
.record_request(event, Timestamp::now(), classification)
|
||||
.await
|
||||
{
|
||||
tracing::error!(event_id = %event.id.to_hex(), error = %error, "Failed to record NIP-62 vanish lifecycle");
|
||||
return reject_error(format!(
|
||||
"internal error recording vanish lifecycle: {error}"
|
||||
));
|
||||
}
|
||||
|
||||
if !targets_relay {
|
||||
tracing::debug!(
|
||||
@@ -314,10 +326,28 @@ impl DeletionService {
|
||||
|
||||
let author = event.pubkey;
|
||||
|
||||
// 1. Record the vanish so re-submission of the author's events is blocked.
|
||||
if let Err(e) = self.ctx.tombstones().record_vanish(event).await {
|
||||
tracing::error!(error = %e, author = %author.to_hex(), "Failed to record vanish tombstone");
|
||||
return reject_error(format!("internal error recording vanish: {e}"));
|
||||
if self.ctx.config.deletion_request_disrespector {
|
||||
let would_vanish = match self.policy.would_nip62_vanish_stored_data(event).await {
|
||||
Ok(would_vanish) => would_vanish,
|
||||
Err(error) => {
|
||||
tracing::error!(event_id = %event.id.to_hex(), error = %error, "Failed to read-only evaluate NIP-62 vanish request");
|
||||
return reject_error(format!(
|
||||
"internal error evaluating vanish request: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if would_vanish {
|
||||
if let Err(result) = self.mark_vanish_request_used(event).await {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
event_id = %event.id.to_hex(),
|
||||
author = %author.to_hex(),
|
||||
would_vanish,
|
||||
"Disrespector mode: stored and read-only evaluated NIP-62 vanish request"
|
||||
);
|
||||
return WritePolicyResult::Accept;
|
||||
}
|
||||
|
||||
let mut outcome = match self.policy.apply_nip62_vanish(event).await {
|
||||
@@ -334,22 +364,8 @@ impl DeletionService {
|
||||
outcome.merge(self.evict_author_from_purgatory(&author));
|
||||
|
||||
if outcome.used() {
|
||||
match self
|
||||
.ctx
|
||||
.tombstones()
|
||||
.mark_request_used(&event.id, Timestamp::now())
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return reject_error(
|
||||
"internal error updating vanish lifecycle: missing metadata",
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(event_id = %event.id.to_hex(), error = %e, "Failed to mark used NIP-62 vanish request");
|
||||
return reject_error(format!("internal error updating vanish lifecycle: {e}"));
|
||||
}
|
||||
if let Err(result) = self.mark_vanish_request_used(event).await {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +381,29 @@ impl DeletionService {
|
||||
WritePolicyResult::Accept
|
||||
}
|
||||
|
||||
async fn mark_vanish_request_used(
|
||||
&self,
|
||||
event: &Event,
|
||||
) -> std::result::Result<(), WritePolicyResult> {
|
||||
match self
|
||||
.ctx
|
||||
.tombstones()
|
||||
.mark_request_used(&event.id, Timestamp::now())
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => Ok(()),
|
||||
Ok(None) => Err(reject_error(
|
||||
"internal error updating vanish lifecycle: missing metadata",
|
||||
)),
|
||||
Err(error) => {
|
||||
tracing::error!(event_id = %event.id.to_hex(), error = %error, "Failed to mark used NIP-62 vanish request");
|
||||
Err(reject_error(format!(
|
||||
"internal error updating vanish lifecycle: {error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admission_hooks(&self) -> DeletionAdmissionHooks<'_> {
|
||||
DeletionAdmissionHooks { deletion: self }
|
||||
}
|
||||
@@ -798,6 +837,26 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn vanish(keys: &Keys, content: &str) -> Event {
|
||||
EventBuilder::new(Kind::RequestToVanish, content)
|
||||
.tags(vec![nostr_relay_builder::prelude::Tag::custom(
|
||||
"relay",
|
||||
vec!["ALL_RELAYS".to_string()],
|
||||
)])
|
||||
.finalize(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn non_targeting_vanish(keys: &Keys) -> Event {
|
||||
EventBuilder::new(Kind::RequestToVanish, "non-targeting")
|
||||
.tags(vec![nostr_relay_builder::prelude::Tag::custom(
|
||||
"relay",
|
||||
vec!["wss://other.example".to_string()],
|
||||
)])
|
||||
.finalize(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_expiry_uses_exact_deadline_boundary() {
|
||||
let service = DeletionService::new(context(crate::config::Config {
|
||||
@@ -962,4 +1021,331 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targeted_vanish_without_stored_data_is_actionable_and_unused() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let request = vanish(&Keys::generate(), "empty");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
let record = ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.classification,
|
||||
RequestClassification::LocallyActionable
|
||||
);
|
||||
assert_eq!(record.last_used_at, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targeted_vanish_marks_used_after_main_database_removal() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let target = EventBuilder::new(Kind::TextNote, "vanish me")
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.database.save_event(&target).await.unwrap();
|
||||
let request = vanish(&keys, "main-db");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert!(ctx
|
||||
.database
|
||||
.event_by_id(&target.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targeted_vanish_marks_used_after_purgatory_only_removal() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let state = EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(vec![nostr_relay_builder::prelude::Tag::identifier(
|
||||
"purgatory-only",
|
||||
)])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.purgatory.add_state(
|
||||
state.clone(),
|
||||
"purgatory-only".to_string(),
|
||||
keys.public_key(),
|
||||
false,
|
||||
);
|
||||
let request = vanish(&keys, "purgatory-only");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert!(ctx.purgatory.find_state("purgatory-only").is_empty());
|
||||
assert!(ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_targeting_vanish_is_unused_and_never_gates() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let target = EventBuilder::new(Kind::TextNote, "keep me")
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.database.save_event(&target).await.unwrap();
|
||||
let request = non_targeting_vanish(&keys);
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert!(ctx
|
||||
.database
|
||||
.event_by_id(&target.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
let record = ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.classification,
|
||||
RequestClassification::NonTargetingNip62
|
||||
);
|
||||
assert_eq!(record.last_used_at, None);
|
||||
let later = EventBuilder::new(Kind::TextNote, "allowed")
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
assert!(service.gate(&later).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_targeting_vanish_takes_precedence_over_disrespector_classification() {
|
||||
let ctx = context(crate::config::Config {
|
||||
deletion_request_disrespector: true,
|
||||
..crate::config::Config::for_testing()
|
||||
});
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let request = non_targeting_vanish(&Keys::generate());
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert_eq!(
|
||||
ctx.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.classification,
|
||||
RequestClassification::NonTargetingNip62
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disrespector_vanish_marks_main_database_and_purgatory_matches_used_without_mutating() {
|
||||
let ctx = context(crate::config::Config {
|
||||
deletion_request_disrespector: true,
|
||||
..crate::config::Config::for_testing()
|
||||
});
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let target = EventBuilder::new(Kind::TextNote, "preserve me")
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.database.save_event(&target).await.unwrap();
|
||||
let state = EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(vec![nostr_relay_builder::prelude::Tag::identifier(
|
||||
"preserve-purgatory",
|
||||
)])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.purgatory.add_state(
|
||||
state.clone(),
|
||||
"preserve-purgatory".to_string(),
|
||||
keys.public_key(),
|
||||
false,
|
||||
);
|
||||
let request = vanish(&keys, "disrespector-data");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert!(ctx
|
||||
.database
|
||||
.event_by_id(&target.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(ctx
|
||||
.purgatory
|
||||
.find_state("preserve-purgatory")
|
||||
.iter()
|
||||
.any(|entry| entry.event.id == state.id));
|
||||
let record = ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(record.classification, RequestClassification::Disrespector);
|
||||
assert!(record.last_used_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disrespector_vanish_marks_purgatory_only_match_used_without_mutating() {
|
||||
let ctx = context(crate::config::Config {
|
||||
deletion_request_disrespector: true,
|
||||
..crate::config::Config::for_testing()
|
||||
});
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let state = EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(vec![nostr_relay_builder::prelude::Tag::identifier(
|
||||
"disrespector-purgatory",
|
||||
)])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
ctx.purgatory.add_state(
|
||||
state.clone(),
|
||||
"disrespector-purgatory".to_string(),
|
||||
keys.public_key(),
|
||||
false,
|
||||
);
|
||||
let request = vanish(&keys, "disrespector-purgatory-only");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert!(ctx
|
||||
.purgatory
|
||||
.find_state("disrespector-purgatory")
|
||||
.iter()
|
||||
.any(|entry| entry.event.id == state.id));
|
||||
assert!(ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disrespector_vanish_noop_remains_unused() {
|
||||
let ctx = context(crate::config::Config {
|
||||
deletion_request_disrespector: true,
|
||||
..crate::config::Config::for_testing()
|
||||
});
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let request = vanish(&Keys::generate(), "disrespector-empty");
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert_eq!(
|
||||
ctx.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_vanish_replay_keeps_original_first_seen_at() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let request = vanish(&Keys::generate(), "replay");
|
||||
ctx.tombstones
|
||||
.record_request(
|
||||
&request,
|
||||
Timestamp::from_secs(10),
|
||||
RequestClassification::LocallyActionable,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
service.handle_vanish(&request).await,
|
||||
WritePolicyResult::Accept
|
||||
));
|
||||
assert_eq!(
|
||||
ctx.tombstones
|
||||
.lifecycle_for_request(&request.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.first_seen_at,
|
||||
Timestamp::from_secs(10)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vanish_gate_promotes_and_updates_only_one_deterministic_request() {
|
||||
let ctx = context(crate::config::Config::for_testing());
|
||||
let service = DeletionService::new(ctx.clone());
|
||||
let keys = Keys::generate();
|
||||
let older = vanish(&keys, "older");
|
||||
let newer = vanish(&keys, "newer");
|
||||
let now = Timestamp::now().as_secs();
|
||||
for (request, first_seen_at) in [(&older, now - 2), (&newer, now - 1)] {
|
||||
ctx.tombstones
|
||||
.record_request(
|
||||
request,
|
||||
Timestamp::from_secs(first_seen_at),
|
||||
RequestClassification::LocallyActionable,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let incoming = EventBuilder::new(Kind::TextNote, "blocked")
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
assert!(service.gate(&incoming).await.is_some());
|
||||
assert!(ctx.database.event_by_id(&older.id).await.unwrap().is_some());
|
||||
assert!(ctx
|
||||
.tombstones
|
||||
.lifecycle_for_request(&older.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
ctx.tombstones
|
||||
.lifecycle_for_request(&newer.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.last_used_at,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user