mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 07:28:23 +00:00
feat(deletion): add deletion-request-disrespector archival mode
Implement the NGIT_DELETION_REQUEST_DISRESPECTOR config option from the deletion-requests design doc. When enabled, the relay acts as an archival server: incoming NIP-09 (kind 5) deletion requests are still accepted and stored (the client gets an OK) but are NOT acted upon — no purgatory eviction, no main-DB deletion, and no tombstone recorded, so targeted events remain fully accessible. This only affects NIP-09 user-initiated deletions; it does not influence NIP-62 vanish handling or blacklist-triggered deletions. - config: add deletion_request_disrespector (bool, default false) with NGIT_DELETION_REQUEST_DISRESPECTOR env / --deletion-request-disrespector CLI. - deletion policy: short-circuit DeletionPolicy::handle when disrespector is set, accepting but not processing the kind-5 request. - nip11: advertise NIP-09 (9) in supported_nips only when disrespector is false, so clients can discover whether the relay honours deletions. - docs/config/nix/.env: document the option across all four sources. Adds unit tests for config parsing, the policy short-circuit (no purgatory eviction, no tombstone, no main-DB deletion), and the conditional NIP-11 advertisement. Integration-test coverage (TestRelay-driven end-to-end) is staged separately and still TODO.
This commit is contained in:
@@ -238,6 +238,29 @@
|
||||
# Default: false
|
||||
# NGIT_GRASP06_ENABLE=false
|
||||
|
||||
# ============================================================================
|
||||
# DELETION REQUESTS (NIP-09)
|
||||
# ============================================================================
|
||||
|
||||
# Deletion request disrespector: ignore NIP-09 deletion requests (archival mode)
|
||||
#
|
||||
# When enabled, incoming NIP-09 (kind 5) deletion requests are STORED but NOT
|
||||
# acted upon: targeted events remain fully accessible. This makes the relay an
|
||||
# archival server, preserving content and preventing "left-pad" scenarios.
|
||||
# NIP-11 supported_nips will NOT advertise NIP-09 (deletion) in this mode.
|
||||
#
|
||||
# This ONLY affects NIP-09 user-initiated deletions. It does NOT prevent
|
||||
# blacklist-triggered deletions (operator moderation: spam/malware/abuse).
|
||||
#
|
||||
# When disabled (default), deletion requests are honoured: targeted events are
|
||||
# deleted and re-submission stays rejected. NIP-09 is advertised in NIP-11.
|
||||
#
|
||||
# See: docs/explanation/deletion-requests.md
|
||||
#
|
||||
# CLI: --deletion-request-disrespector
|
||||
# Default: false
|
||||
# NGIT_DELETION_REQUEST_DISRESPECTOR=false
|
||||
|
||||
# ============================================================================
|
||||
# REPOSITORY WHITELIST
|
||||
# ============================================================================
|
||||
|
||||
@@ -9,9 +9,11 @@ This document describes the planned architecture for NIP-09 deletion request sup
|
||||
## Implemented: ngit-grasp owns NIP-09 / NIP-62 handling
|
||||
|
||||
> The rest of this document is the **planned** design for further work
|
||||
> (holding database, cascade, archival, recovery, disrespector mode). It is
|
||||
> still aspirational. What follows in this section is what is **actually built
|
||||
> today** and is the foundation that work builds on.
|
||||
> (holding database, cascade, archival, recovery). It is still aspirational.
|
||||
> What follows in this section is what is **actually built today** and is the
|
||||
> foundation that work builds on. The `deletion-request-disrespector` archival
|
||||
> mode is now implemented (see below); the holding DB, cascade, git archival,
|
||||
> and recovery remain planned.
|
||||
|
||||
Up to the rust-nostr 0.45 bump, ngit-grasp relied on the LMDB backend's
|
||||
*automatic* NIP-09 / NIP-62 processing (`NostrLmdb` defaults
|
||||
@@ -70,6 +72,18 @@ gives us durable, queryable deletion/vanish state for free and is the natural
|
||||
foundation for the later cascade/recovery work (recovery = remove the
|
||||
tombstone; cascade = walk the references of a recorded deletion).
|
||||
|
||||
- **Disrespector mode** (`deletion_request_disrespector`, env
|
||||
`NGIT_DELETION_REQUEST_DISRESPECTOR`, CLI `--deletion-request-disrespector`):
|
||||
when enabled, [`DeletionPolicy::handle`](../../src/nostr/policy/deletion.rs)
|
||||
short-circuits at the top — the kind-5 deletion request is still accepted (and
|
||||
therefore stored in the main DB so clients get an OK), but it is **not** acted
|
||||
upon: no purgatory eviction, no main-DB deletion, and no tombstone is recorded.
|
||||
Targeted events stay fully accessible, making the relay an archival server.
|
||||
This only affects NIP-09 (kind 5); NIP-62 vanish handling is unchanged.
|
||||
NIP-11 advertisement reflects the mode: NIP-09 (`9`) is included in
|
||||
`supported_nips` only when disrespector is `false`
|
||||
([`src/http/nip11.rs`](../../src/http/nip11.rs)).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -231,7 +245,13 @@ Result: Archival relay preserves all content
|
||||
- Archival relays still need ability to moderate malicious content
|
||||
- Different policy goals: preservation vs. safety
|
||||
|
||||
**Implementation Note:** We need to verify that `nostr-relay-builder` doesn't automatically process deletion requests at the relay library level. If it does, we'll need to override or disable this behavior when disrespector mode is enabled. This will be investigated in Phase 6.
|
||||
**Implementation Note:** Implemented. The LMDB backend's automatic NIP-09
|
||||
processing is disabled (`process_nip09(false)`); ngit-grasp owns deletion
|
||||
handling in [`DeletionPolicy::handle`](../../src/nostr/policy/deletion.rs), which
|
||||
short-circuits when `deletion_request_disrespector` is set — storing but not
|
||||
acting on the kind-5 request. NIP-09 is omitted from the NIP-11
|
||||
`supported_nips` list in this mode. The git-archival / holding-DB pieces of the
|
||||
broader design remain planned.
|
||||
|
||||
## Recovery Mechanism
|
||||
|
||||
@@ -493,10 +513,11 @@ How long to retain archived events and git data before permanent deletion. Provi
|
||||
|
||||
## NIP-11 Advertisement
|
||||
|
||||
Deletion support is **conditionally advertised** in NIP-11 relay information:
|
||||
Deletion support is **conditionally advertised** in NIP-11 relay information
|
||||
(implemented in [`src/http/nip11.rs`](../../src/http/nip11.rs)):
|
||||
|
||||
- **When `deletion_request_disrespector = false`:** Include `"deletion"` in supported NIPs array
|
||||
- **When `deletion_request_disrespector = true`:** Do NOT include `"deletion"` (archival mode doesn't honor deletions)
|
||||
- **When `deletion_request_disrespector = false`:** include `9` (`"deletion"`) in the supported NIPs array
|
||||
- **When `deletion_request_disrespector = true`:** do NOT include `9` (archival mode doesn't honor deletions)
|
||||
|
||||
This allows clients to discover whether a relay respects deletion requests.
|
||||
|
||||
|
||||
@@ -1038,6 +1038,57 @@ Event blacklist does **not** affect NIP-11 metadata:
|
||||
|
||||
---
|
||||
|
||||
### Deletion Requests (NIP-09)
|
||||
|
||||
#### `NGIT_DELETION_REQUEST_DISRESPECTOR`
|
||||
|
||||
**Description:** Ignore NIP-09 deletion requests and act as an archival server
|
||||
**Type:** Boolean
|
||||
**Default:** `false` (deletion requests are honoured)
|
||||
**Required:** No
|
||||
**CLI:** `--deletion-request-disrespector`
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- When `false` (default):
|
||||
- NIP-09 (kind 5) deletion requests are honoured: targeted events are
|
||||
hard-deleted from the relay, matching purgatory entries are evicted, and a
|
||||
persistent tombstone is recorded so re-submission of the deleted event stays
|
||||
rejected across restarts.
|
||||
- NIP-11 `supported_nips` includes `9` (deletion).
|
||||
- When `true`:
|
||||
- Incoming NIP-09 deletion requests are still **stored** (the client receives
|
||||
an OK), but they are **not acted upon**. Targeted events remain fully
|
||||
accessible. This makes the relay an archival server, preserving content and
|
||||
preventing "left-pad" scenarios.
|
||||
- NIP-11 `supported_nips` does **not** include `9`, so clients can discover
|
||||
that this relay does not honour deletions.
|
||||
|
||||
**IMPORTANT:** This setting ONLY affects NIP-09 user-initiated deletions. It does
|
||||
**NOT** prevent blacklist-triggered deletions, which are an operator moderation
|
||||
mechanism (spam/malware/abuse) that archival relays still need.
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- Community archival relays
|
||||
- Research / historical preservation
|
||||
- Backup / mirror relays
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Archival relay: preserve deleted content (disrespect NIP-09)
|
||||
NGIT_DELETION_REQUEST_DISRESPECTOR=true
|
||||
|
||||
# Standard relay: honour deletion requests (default)
|
||||
NGIT_DELETION_REQUEST_DISRESPECTOR=false
|
||||
```
|
||||
|
||||
See [`docs/explanation/deletion-requests.md`](../explanation/deletion-requests.md)
|
||||
for the full design rationale.
|
||||
|
||||
---
|
||||
|
||||
### Rate Limiting & DoS Protection
|
||||
|
||||
#### `NGIT_MAX_CONNECTIONS`
|
||||
|
||||
@@ -257,6 +257,27 @@ let
|
||||
'';
|
||||
};
|
||||
|
||||
deletionRequestDisrespector = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Ignore NIP-09 deletion requests and act as an archival server.
|
||||
|
||||
When enabled, incoming NIP-09 (kind 5) deletion requests are stored
|
||||
but NOT acted upon: targeted events remain fully accessible. This
|
||||
preserves content and prevents "left-pad" scenarios. NIP-11
|
||||
supported_nips will NOT advertise NIP-09 (deletion).
|
||||
|
||||
This ONLY affects NIP-09 user-initiated deletions. It does NOT prevent
|
||||
blacklist-triggered deletions (operator moderation).
|
||||
|
||||
When disabled (default), deletion requests are honoured and NIP-09 is
|
||||
advertised in NIP-11.
|
||||
|
||||
See: docs/explanation/deletion-requests.md
|
||||
'';
|
||||
};
|
||||
|
||||
repositoryBlacklist = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
@@ -356,6 +377,8 @@ let
|
||||
NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist;
|
||||
NGIT_LOG_LEVEL = cfg.logLevel;
|
||||
NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false";
|
||||
NGIT_DELETION_REQUEST_DISRESPECTOR =
|
||||
if cfg.deletionRequestDisrespector then "true" else "false";
|
||||
} // optionalAttrs (cfg.maxConnections != null) {
|
||||
NGIT_MAX_CONNECTIONS = toString cfg.maxConnections;
|
||||
} // optionalAttrs (cfg.relayName != null) {
|
||||
|
||||
@@ -480,6 +480,26 @@ pub struct Config {
|
||||
#[arg(long, env = "NGIT_EVENT_BLACKLIST", default_value = "")]
|
||||
pub event_blacklist: String,
|
||||
|
||||
/// Deletion request disrespector: ignore NIP-09 deletion requests (archival mode)
|
||||
///
|
||||
/// When `true`, the relay stores incoming NIP-09 (kind 5) deletion requests but
|
||||
/// does NOT act on them: targeted events remain fully accessible. This makes the
|
||||
/// relay an archival server, preventing "left-pad" scenarios by ensuring at least
|
||||
/// some relays preserve deleted content.
|
||||
///
|
||||
/// This setting ONLY affects NIP-09 user-initiated deletions. It does NOT prevent
|
||||
/// blacklist-triggered deletions, which are an operator moderation mechanism that
|
||||
/// archival relays still need.
|
||||
///
|
||||
/// When `true`, NIP-09 (`"deletion"`) is NOT advertised in the NIP-11 supported
|
||||
/// NIPs list so clients can discover that the relay does not honour deletions.
|
||||
#[arg(
|
||||
long,
|
||||
env = "NGIT_DELETION_REQUEST_DISRESPECTOR",
|
||||
default_value_t = false
|
||||
)]
|
||||
pub deletion_request_disrespector: bool,
|
||||
|
||||
/// Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)
|
||||
#[arg(long, env = "NGIT_MAX_CONNECTIONS")]
|
||||
pub max_connections: Option<usize>,
|
||||
@@ -769,6 +789,7 @@ impl Config {
|
||||
repository_whitelist: String::new(),
|
||||
repository_blacklist: String::new(),
|
||||
event_blacklist: String::new(),
|
||||
deletion_request_disrespector: false,
|
||||
max_connections: None,
|
||||
log_level: "debug".to_string(),
|
||||
}
|
||||
@@ -1415,6 +1436,21 @@ mod tests {
|
||||
assert!(!event_blacklist_config.enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deletion_request_disrespector_default() {
|
||||
let config = Config::for_testing();
|
||||
assert!(!config.deletion_request_disrespector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deletion_request_disrespector_enabled() {
|
||||
let config = Config {
|
||||
deletion_request_disrespector: true,
|
||||
..Config::for_testing()
|
||||
};
|
||||
assert!(config.deletion_request_disrespector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_blacklist_check_blacklisted() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
+44
-6
@@ -105,12 +105,24 @@ impl RelayInformationDocument {
|
||||
description: config.relay_description.clone(),
|
||||
pubkey: config.relay_owner_pubkey_hex().ok(),
|
||||
contact: None, // Could be added to config if needed
|
||||
supported_nips: vec![
|
||||
1, // NIP-01: Basic protocol flow
|
||||
11, // NIP-11: Relay information document (this!)
|
||||
34, // NIP-34: Git repository announcements
|
||||
77, // NIP-77: Negentropy sync (reconciliation protocol)
|
||||
],
|
||||
supported_nips: {
|
||||
let mut nips = vec![
|
||||
1, // NIP-01: Basic protocol flow
|
||||
11, // NIP-11: Relay information document (this!)
|
||||
34, // NIP-34: Git repository announcements
|
||||
77, // NIP-77: Negentropy sync (reconciliation protocol)
|
||||
];
|
||||
// NIP-09 (deletion) is honoured only when not running as an
|
||||
// archival "disrespector" relay. When disrespector mode is on
|
||||
// the relay stores but ignores deletion requests, so we must not
|
||||
// advertise NIP-09 support — clients can then discover that this
|
||||
// relay does not honour deletions.
|
||||
if !config.deletion_request_disrespector {
|
||||
nips.push(9); // NIP-09: Event deletion requests
|
||||
nips.sort_unstable();
|
||||
}
|
||||
nips
|
||||
},
|
||||
software: "https://gitworkshop.dev/danconwaydev.com/ngit-grasp".to_string(),
|
||||
version: match option_env!("GIT_COMMIT_SHORT") {
|
||||
Some(commit) => format!("{}-{}", env!("CARGO_PKG_VERSION"), commit),
|
||||
@@ -158,6 +170,8 @@ mod tests {
|
||||
assert!(doc.supported_nips.contains(&11));
|
||||
assert!(doc.supported_nips.contains(&34));
|
||||
assert!(doc.supported_nips.contains(&77));
|
||||
// NIP-09 (deletion) advertised by default (disrespector off).
|
||||
assert!(doc.supported_nips.contains(&9));
|
||||
// Without archive mode, only GRASP-01 and GRASP-02
|
||||
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
|
||||
assert!(doc.repo_acceptance_criteria.contains("None"));
|
||||
@@ -309,4 +323,28 @@ mod tests {
|
||||
vec!["GRASP-01", "GRASP-02", "GRASP-06"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_advertises_deletion_by_default() {
|
||||
let config = Config::for_testing();
|
||||
let doc = RelayInformationDocument::from_config(&config);
|
||||
|
||||
// NIP-09 (deletion) is honoured (and advertised) by default.
|
||||
assert!(doc.supported_nips.contains(&9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_omits_deletion_in_disrespector_mode() {
|
||||
let mut config = Config::for_testing();
|
||||
config.deletion_request_disrespector = true;
|
||||
|
||||
let doc = RelayInformationDocument::from_config(&config);
|
||||
|
||||
// Archival relays do not honour deletions, so NIP-09 must not be
|
||||
// advertised.
|
||||
assert!(!doc.supported_nips.contains(&9));
|
||||
// Other NIPs are unaffected.
|
||||
assert!(doc.supported_nips.contains(&1));
|
||||
assert!(doc.supported_nips.contains(&34));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,27 @@ impl DeletionPolicy {
|
||||
/// Only the event author can delete their own events. Authorship is checked
|
||||
/// per-target: `e`-tag targets must be authored by the deleter, and `a`-tag
|
||||
/// coordinates must carry the deleter's pubkey.
|
||||
///
|
||||
/// ## Disrespector mode
|
||||
///
|
||||
/// When `deletion_request_disrespector` is enabled the relay acts as an
|
||||
/// archival server: the kind-5 event is still accepted (and stored in the
|
||||
/// main database) so clients see an OK and the request is preserved, but it
|
||||
/// is NOT acted upon — no purgatory eviction, no main-DB deletion, and no
|
||||
/// tombstone recording. The targeted events therefore remain fully
|
||||
/// accessible. This only affects NIP-09 user-initiated deletions; it does
|
||||
/// not influence blacklist-triggered deletions.
|
||||
pub async fn handle(&self, event: &Event) -> WritePolicyResult {
|
||||
// Archival mode: store the deletion 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-09 deletion request without acting on it"
|
||||
);
|
||||
return WritePolicyResult::Accept;
|
||||
}
|
||||
|
||||
// Validate authorship of all targets first. If any `e`-tag target exists
|
||||
// in the main DB and is owned by someone else, the whole deletion is
|
||||
// invalid (mirrors the backend's `handle_deletion_event` returning
|
||||
@@ -444,6 +464,16 @@ mod tests {
|
||||
PolicyContext::new_for_test("test.example.com", db, PathBuf::new(), purgatory, config)
|
||||
}
|
||||
|
||||
fn make_disrespector_context() -> PolicyContext {
|
||||
let db = Arc::new(nostr_memory::MemoryDatabase::unbounded());
|
||||
let purgatory = Arc::new(Purgatory::new(PathBuf::new()));
|
||||
let config = crate::config::Config {
|
||||
deletion_request_disrespector: true,
|
||||
..crate::config::Config::for_testing()
|
||||
};
|
||||
PolicyContext::new_for_test("test.example.com", db, PathBuf::new(), purgatory, config)
|
||||
}
|
||||
|
||||
fn make_announcement_event(keys: &Keys, identifier: &str) -> Event {
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![
|
||||
@@ -645,4 +675,88 @@ mod tests {
|
||||
"Purgatory entry should NOT be removed: entry is newer than deletion request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disrespector_accepts_but_does_not_remove_purgatory_entry() {
|
||||
let ctx = make_disrespector_context();
|
||||
let keys = Keys::generate();
|
||||
let identifier = "my-repo";
|
||||
|
||||
let announcement = make_announcement_event(&keys, identifier);
|
||||
add_to_purgatory(&ctx, &announcement, identifier);
|
||||
|
||||
assert!(ctx
|
||||
.purgatory
|
||||
.has_purgatory_announcement(&keys.public_key(), identifier));
|
||||
|
||||
// Deletion event the author is allowed to make.
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![
|
||||
Tag::event(announcement.id),
|
||||
Tag::custom("k", vec!["30617"]),
|
||||
])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let policy = DeletionPolicy::new(ctx.clone());
|
||||
let result = policy.handle(&deletion).await;
|
||||
|
||||
// The deletion request is accepted (stored) ...
|
||||
assert!(matches!(result, WritePolicyResult::Accept));
|
||||
// ... but NOT acted upon: the purgatory entry remains.
|
||||
assert!(
|
||||
ctx.purgatory
|
||||
.has_purgatory_announcement(&keys.public_key(), identifier),
|
||||
"Disrespector mode must NOT remove the purgatory entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disrespector_does_not_record_tombstone() {
|
||||
let ctx = make_disrespector_context();
|
||||
let keys = Keys::generate();
|
||||
let target = EventId::all_zeros();
|
||||
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![Tag::event(target)])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let policy = DeletionPolicy::new(ctx.clone());
|
||||
let result = policy.handle(&deletion).await;
|
||||
|
||||
assert!(matches!(result, WritePolicyResult::Accept));
|
||||
// No tombstone recorded -> re-submission of the target is NOT blocked.
|
||||
assert!(
|
||||
!ctx.tombstones.is_event_deleted(&target).await,
|
||||
"Disrespector mode must NOT record a deletion tombstone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disrespector_does_not_delete_target_from_main_db() {
|
||||
let ctx = make_disrespector_context();
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Store a target event in the main DB.
|
||||
let target = make_announcement_event(&keys, "keep-me");
|
||||
ctx.database.save_event(&target).await.unwrap();
|
||||
|
||||
// Author requests its deletion.
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![Tag::event(target.id)])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let policy = DeletionPolicy::new(ctx.clone());
|
||||
let result = policy.handle(&deletion).await;
|
||||
|
||||
assert!(matches!(result, WritePolicyResult::Accept));
|
||||
// Target must still be present in the main database.
|
||||
let still_there = ctx.database.event_by_id(&target.id).await.unwrap();
|
||||
assert!(
|
||||
still_there.is_some(),
|
||||
"Disrespector mode must NOT delete the target from the main DB"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user