mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 15:38:25 +00:00
feat(nostr): implement NIP-09/NIP-62 ourselves instead of via the backend
The LMDB backend auto-processes NIP-09 (deletion) and NIP-62 (request to vanish) when process_nip09/process_nip62 are true (the defaults). That handling removed deleted/vanished events from the main DB and blocked their re-submission via the relay-builder check_id gate, which consulted the backend's internal deleted-ids / deleted-coordinates / vanished-public-keys LMDB tables — tables not exposed by the NostrDatabase trait. Disable that automatic processing (process_nip09(false), process_nip62(false)) and implement NIP-09 / NIP-62 in ngit-grasp itself, bypassing the backend so we can apply our own customisations in the future. The existing observable behaviour is preserved: - Add a persistent tombstone store (src/nostr/tombstones.rs): a dedicated nostr database (separate NostrLmdb under <relay_data_path>/tombstones, or MemoryDatabase for the memory backend) that stores the kind-5/kind-62 requests themselves and derives deleted/vanished state by querying them. Survives restarts like the backend's internal tables did. - Add a deletion gate at the top of admit_event reproducing the now-silent check_id == Deleted rejection (deleted id, deleted coordinate, or event from a vanished pubkey). - Extend DeletionPolicy to validate author ownership, hard-delete targeted events from the main DB via NostrDatabase::delete(filter), and record the deletion tombstone — in addition to the existing purgatory eviction / bare-repo deletion. - Add a kind-62 handler that records the vanish tombstone, deletes the author's main-DB events, and evicts their purgatory entries / bare repos. - Remove the now-unreachable DatabaseEventStatus::Deleted branch in the PR handler (deletion is rejected upstream by the gate). No cascade, holding DB, archival, recovery, or disrespector — those remain planned for later work.
This commit is contained in:
@@ -6,6 +6,72 @@ 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.
|
||||
|
||||
Up to the rust-nostr 0.45 bump, ngit-grasp relied on the LMDB backend's
|
||||
*automatic* NIP-09 / NIP-62 processing (`NostrLmdb` defaults
|
||||
`process_nip09 = true`, `process_nip62 = true`). The backend silently removed
|
||||
deleted/vanished events from the main DB and blocked their re-submission via the
|
||||
relay-builder `check_id` gate, which consulted the backend's internal
|
||||
`deleted-ids` / `deleted-coordinates` / `vanished-public-keys` LMDB tables.
|
||||
|
||||
We disable that automatic processing
|
||||
([`src/nostr/builder.rs`](../../src/nostr/builder.rs) sets
|
||||
`process_nip09(false)` and `process_nip62(false)` on both the main database and
|
||||
the tombstone database) and implement NIP-09 / NIP-62 in ngit-grasp itself,
|
||||
bypassing the backend so we can apply our own customisations in the future. The
|
||||
observable behaviour is unchanged; only the implementation moved. There is no
|
||||
cascade, holding DB, archival, recovery, or disrespector yet — those remain
|
||||
planned (see below).
|
||||
|
||||
**Components added:**
|
||||
|
||||
- **Tombstone store** ([`src/nostr/tombstones.rs`](../../src/nostr/tombstones.rs)):
|
||||
a dedicated, persistent nostr database (a separate `NostrLmdb` at
|
||||
`<relay_data_path>/tombstones`, or `MemoryDatabase` for the memory backend)
|
||||
that stores the kind-5 deletion requests and kind-62 vanish requests
|
||||
themselves. Deleted/vanished state is *derived* by querying it:
|
||||
- kind-5 with an `e` tag → that event id is deleted,
|
||||
- kind-5 with an `a` tag → that coordinate is deleted up to the kind-5's
|
||||
`created_at`,
|
||||
- kind-62 by pubkey `P` → pubkey `P` is vanished.
|
||||
Because the requests are stored in a real database, the deleted/vanished state
|
||||
survives restarts exactly as the backend's internal tables did.
|
||||
|
||||
- **Deletion gate** (`Nip34WritePolicy::deletion_gate` in
|
||||
[`src/nostr/builder.rs`](../../src/nostr/builder.rs)): runs at the top of
|
||||
`admit_event` and reproduces the now-silent library `check_id == Deleted`
|
||||
rejection — it rejects re-submission of a deleted event id, a deleted
|
||||
coordinate, or any event from a vanished pubkey, by consulting the tombstone
|
||||
store.
|
||||
|
||||
- **NIP-09 handler** ([`src/nostr/policy/deletion.rs`](../../src/nostr/policy/deletion.rs)):
|
||||
in addition to the existing purgatory eviction (and bare-repo deletion), it now
|
||||
validates author ownership, hard-deletes the targeted events from the main
|
||||
database via `NostrDatabase::delete(filter)`, and records the deletion in the
|
||||
tombstone store. An `e`-tag delete targeting an event owned by a different
|
||||
author is rejected (matching the backend's `InvalidDelete`).
|
||||
|
||||
- **NIP-62 handler** (`Nip34WritePolicy::handle_vanish` in
|
||||
[`src/nostr/builder.rs`](../../src/nostr/builder.rs)): for a vanish request
|
||||
targeting this relay (or `ALL_RELAYS`), records the vanish tombstone,
|
||||
hard-deletes the author's events from the main DB, and evicts the author's
|
||||
purgatory entries (deleting their bare repos).
|
||||
|
||||
**Why store the requests rather than a bespoke tombstone table?** The
|
||||
`NostrDatabase` trait does not expose the backend's internal deletion tables, so
|
||||
we cannot read or write them. Persisting the request events in our own database
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ngit-grasp will implement optional support for NIP-09 deletion requests, allowing repository owners to remove their repositories from the relay while providing safeguards against the "left-pad problem" through configurable archival behavior.
|
||||
@@ -480,7 +546,7 @@ When implementation is complete, the following documentation will be updated:
|
||||
|
||||
**Phase 6: Analysis & Edge Cases** 🔄 (Planned)
|
||||
- Background cleanup timing strategy (daily doesn't work with 3-second test retention)
|
||||
- rust-nostr deletion behavior investigation (does relay builder auto-process deletions?)
|
||||
- ~~rust-nostr deletion behavior investigation (does relay builder auto-process deletions?)~~ ✅ **Resolved:** the LMDB backend auto-processes NIP-09/NIP-62 when `process_nip09`/`process_nip62` are `true` (the defaults); ngit-grasp now runs with both `false` and implements the handling itself (see the "Implemented" section above).
|
||||
- Author validation enforcement and testing
|
||||
- Max depth edge case analysis
|
||||
- Large-scale testing
|
||||
|
||||
+240
-26
@@ -56,6 +56,7 @@ impl std::fmt::Debug for Nip34WritePolicy {
|
||||
impl Nip34WritePolicy {
|
||||
pub fn new(
|
||||
database: SharedDatabase,
|
||||
tombstones: crate::nostr::tombstones::Tombstones,
|
||||
git_data_path: impl Into<std::path::PathBuf>,
|
||||
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
|
||||
config: crate::config::Config,
|
||||
@@ -64,6 +65,7 @@ impl Nip34WritePolicy {
|
||||
let ctx = PolicyContext::new(
|
||||
&config.domain,
|
||||
database,
|
||||
tombstones,
|
||||
git_data_path,
|
||||
purgatory,
|
||||
config.clone(),
|
||||
@@ -364,13 +366,14 @@ impl Nip34WritePolicy {
|
||||
}
|
||||
|
||||
// duplicate check in db
|
||||
//
|
||||
// Note: the database runs with `process_nip09(false)`, so `check_id`
|
||||
// only ever returns `Saved` / `NotExistent` here — re-submission of a
|
||||
// deleted PR is already rejected upstream by `deletion_gate`.
|
||||
match &self.ctx.database.check_id(&event.id).await {
|
||||
Ok(DatabaseEventStatus::Saved) => {
|
||||
return duplicate("already have this event");
|
||||
}
|
||||
Ok(DatabaseEventStatus::Deleted) => {
|
||||
return reject_invalid("accepted deletion request for this event");
|
||||
}
|
||||
Err(e) => {
|
||||
return reject_error(format!("internal error: {e}"));
|
||||
}
|
||||
@@ -604,6 +607,182 @@ impl Nip34WritePolicy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletion / vanish gate run before kind-specific handling.
|
||||
///
|
||||
/// Reproduces the rejection the relay-builder's `check_id` gate used to
|
||||
/// provide when the LMDB backend auto-processed NIP-09/NIP-62. Returns
|
||||
/// `Some(rejection)` if the event must be rejected, `None` to continue.
|
||||
///
|
||||
/// Checks, in the same order the backend used:
|
||||
/// 1. author pubkey has requested to vanish from this relay
|
||||
/// 2. the event id was deleted by a recorded kind-5 (`e` tag)
|
||||
/// 3. the event's replaceable/addressable coordinate was deleted by a
|
||||
/// recorded kind-5 (`a` tag) at or after this event's `created_at`
|
||||
///
|
||||
/// Fresh kind-5 / kind-62 events pass this gate naturally: their tombstone
|
||||
/// is only recorded *after* successful handling, so they are not yet in the
|
||||
/// store when the gate runs.
|
||||
async fn deletion_gate(&self, event: &Event) -> Option<WritePolicyResult> {
|
||||
// 1. Vanished pubkey
|
||||
if self.ctx.tombstones.is_pubkey_vanished(&event.pubkey).await {
|
||||
tracing::debug!(
|
||||
event_id = %event.id.to_hex(),
|
||||
author = %event.pubkey.to_hex(),
|
||||
"Rejected event from vanished pubkey"
|
||||
);
|
||||
return Some(reject_invalid("this pubkey has requested to vanish"));
|
||||
}
|
||||
|
||||
// 2. Deleted event id
|
||||
if self.ctx.tombstones.is_event_deleted(&event.id).await {
|
||||
tracing::debug!(
|
||||
event_id = %event.id.to_hex(),
|
||||
"Rejected re-submission of deleted event"
|
||||
);
|
||||
return Some(reject_invalid("this event is deleted"));
|
||||
}
|
||||
|
||||
// 3. Deleted coordinate (replaceable / addressable events only)
|
||||
if event.kind.is_replaceable() || event.kind.is_addressable() {
|
||||
let coordinate = Self::event_coordinate(event);
|
||||
if let Some(coord) = coordinate {
|
||||
if self
|
||||
.ctx
|
||||
.tombstones
|
||||
.is_coordinate_deleted(&coord, event.created_at)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
event_id = %event.id.to_hex(),
|
||||
coordinate = %coord,
|
||||
"Rejected event whose coordinate was deleted"
|
||||
);
|
||||
return Some(reject_invalid("this event is deleted"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the NIP-01 addressable/replaceable coordinate string
|
||||
/// `<kind>:<pubkey-hex>:<d-identifier>` for an event, matching the format
|
||||
/// used in NIP-09 `a` tags. Returns `None` for non-(addressable/replaceable)
|
||||
/// events.
|
||||
fn event_coordinate(event: &Event) -> Option<String> {
|
||||
if !(event.kind.is_replaceable() || event.kind.is_addressable()) {
|
||||
return None;
|
||||
}
|
||||
let identifier = if event.kind.is_addressable() {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind() == "d")
|
||||
.and_then(|t| t.content())
|
||||
.unwrap_or("")
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!(
|
||||
"{}:{}:{}",
|
||||
event.kind.as_u16(),
|
||||
event.pubkey.to_hex(),
|
||||
identifier
|
||||
))
|
||||
}
|
||||
|
||||
/// Handle a NIP-62 request-to-vanish (kind 62).
|
||||
///
|
||||
/// Reproduces the LMDB backend's vanish behaviour now that we run with
|
||||
/// `process_nip62(false)`:
|
||||
/// - record the vanish request in the persistent tombstone store so future
|
||||
/// events from this pubkey are rejected (the [`deletion_gate`]),
|
||||
/// - hard-delete the author's events from the main database,
|
||||
/// - evict any of the author's purgatory entries and delete their bare repos.
|
||||
///
|
||||
/// Only requests targeting this relay (or `ALL_RELAYS`) are honoured; the
|
||||
/// signer is implicitly the pubkey being vanished, so author validation is
|
||||
/// inherent.
|
||||
async fn handle_vanish(&self, event: &Event) -> WritePolicyResult {
|
||||
use nostr_relay_builder::prelude::nip62;
|
||||
|
||||
// Only honour requests that target this relay (or all relays). We do not
|
||||
// configure a relay_url on the database, matching the historical
|
||||
// behaviour where only ALL_RELAYS requests were actioned; but we accept
|
||||
// (store) any well-formed kind-62 so clients get an OK.
|
||||
let targets_relay = nip62::is_valid_vanish_request_for_relay(event.tags.as_slice(), None);
|
||||
|
||||
if !targets_relay {
|
||||
tracing::debug!(
|
||||
author = %event.pubkey.to_hex(),
|
||||
"kind-62 vanish request does not target this relay; storing without action"
|
||||
);
|
||||
return WritePolicyResult::Accept;
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
|
||||
// 2. Hard-delete the author's events from the main database.
|
||||
let filter = Filter::new().author(author);
|
||||
if let Err(e) = self.ctx.database.delete(filter).await {
|
||||
tracing::error!(error = %e, author = %author.to_hex(), "Failed to delete vanished author's events");
|
||||
return reject_error(format!("internal error processing vanish: {e}"));
|
||||
}
|
||||
|
||||
// 3. Evict the author's purgatory entries (and their bare repos).
|
||||
self.evict_author_from_purgatory(&author);
|
||||
|
||||
tracing::info!(
|
||||
author = %author.to_hex(),
|
||||
"Processed NIP-62 request to vanish: deleted author's events and purgatory entries"
|
||||
);
|
||||
|
||||
WritePolicyResult::Accept
|
||||
}
|
||||
|
||||
/// Remove all purgatory entries authored by `author` and delete any bare
|
||||
/// repositories backing purgatory announcements they owned.
|
||||
fn evict_author_from_purgatory(&self, author: &nostr_relay_builder::prelude::PublicKey) {
|
||||
// Announcements owned by this author.
|
||||
for (repo_id, _) in self.ctx.purgatory.announcements_for_sync() {
|
||||
// repo_id format: "30617:{pubkey_hex}:{identifier}"
|
||||
let parts: Vec<&str> = repo_id.splitn(3, ':').collect();
|
||||
if parts.len() != 3 || parts[1] != author.to_hex() {
|
||||
continue;
|
||||
}
|
||||
let identifier = parts[2];
|
||||
if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
|
||||
if entry.repo_path.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) {
|
||||
tracing::warn!(
|
||||
path = %entry.repo_path.display(),
|
||||
error = %e,
|
||||
"Failed to delete bare repository during vanish processing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctx.purgatory.remove_announcement(author, identifier);
|
||||
}
|
||||
|
||||
// State events authored by this author across all identifiers.
|
||||
for identifier in self.ctx.purgatory.get_all_identifiers() {
|
||||
for entry in self.ctx.purgatory.find_state(&identifier) {
|
||||
if entry.author == *author {
|
||||
self.ctx
|
||||
.purgatory
|
||||
.remove_state_event(&identifier, &entry.event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WritePolicy for Nip34WritePolicy {
|
||||
@@ -624,6 +803,20 @@ impl WritePolicy for Nip34WritePolicy {
|
||||
return WritePolicyResult::reject(MachineReadablePrefix::Blocked, reason);
|
||||
}
|
||||
|
||||
// Deletion / vanish gate.
|
||||
//
|
||||
// The relay-builder library has a `check_id` gate that runs *before*
|
||||
// this policy and used to reject re-submitted deleted events with
|
||||
// "this event is deleted" — but that gate is fed by the LMDB
|
||||
// backend's internal NIP-09/NIP-62 tables, which go silent now that
|
||||
// we run the backend with `process_nip09(false)` /
|
||||
// `process_nip62(false)`. We reproduce that rejection here from our
|
||||
// own persistent `Tombstones` store so deleted events (and events
|
||||
// from vanished pubkeys) stay rejected across restarts.
|
||||
if let Some(rejection) = self.deletion_gate(event).await {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
// Detect if this is a synced event (from proactive sync) vs user-submitted
|
||||
// Sync uses localhost:0 as a dummy address
|
||||
let is_synced = addr.ip().is_loopback() && addr.port() == 0;
|
||||
@@ -645,6 +838,7 @@ impl WritePolicy for Nip34WritePolicy {
|
||||
WritePolicyResult::Accept
|
||||
}
|
||||
Kind::EventDeletion => self.deletion_policy.handle(event).await,
|
||||
Kind::RequestToVanish => self.handle_vanish(event).await,
|
||||
_ => self.handle_related_event(event, "Event").await,
|
||||
}
|
||||
})
|
||||
@@ -677,30 +871,49 @@ pub async fn create_relay(
|
||||
let db_path = Path::new(&config.relay_data_path);
|
||||
|
||||
// Create database based on configuration
|
||||
let database: SharedDatabase = match config.database_backend {
|
||||
DatabaseBackend::Memory => {
|
||||
tracing::info!("Using in-memory database (no persistence)");
|
||||
Arc::new(MemoryDatabase::bounded(NonZeroUsize::new(100_000).unwrap()))
|
||||
}
|
||||
DatabaseBackend::Lmdb => {
|
||||
tracing::info!("Using LMDB backend at: {}", db_path.display());
|
||||
// Ensure the database directory exists
|
||||
std::fs::create_dir_all(db_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create LMDB directory {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
//
|
||||
// NIP-09 (deletion) and NIP-62 (request to vanish) auto-processing is
|
||||
// explicitly disabled on the main database: ngit-grasp owns this handling
|
||||
// (see `DeletionPolicy`, the kind-62 handler, and the `Tombstones` store).
|
||||
// Leaving the backend defaults (`true`) would double-process: the backend
|
||||
// would silently hard-delete events and block re-submission via its own
|
||||
// internal tables that we cannot inspect, conflicting with our purgatory /
|
||||
// bare-repo bookkeeping.
|
||||
let (database, tombstones): (SharedDatabase, crate::nostr::tombstones::Tombstones) =
|
||||
match config.database_backend {
|
||||
DatabaseBackend::Memory => {
|
||||
tracing::info!("Using in-memory database (no persistence)");
|
||||
(
|
||||
Arc::new(MemoryDatabase::bounded(NonZeroUsize::new(100_000).unwrap())),
|
||||
crate::nostr::tombstones::Tombstones::in_memory(),
|
||||
)
|
||||
})?;
|
||||
Arc::new(NostrLmdb::open(db_path).await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to open LMDB database at {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
)
|
||||
})?)
|
||||
}
|
||||
};
|
||||
}
|
||||
DatabaseBackend::Lmdb => {
|
||||
tracing::info!("Using LMDB backend at: {}", db_path.display());
|
||||
// Ensure the database directory exists
|
||||
std::fs::create_dir_all(db_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create LMDB directory {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let db = NostrLmdb::builder(db_path)
|
||||
.process_nip09(false)
|
||||
.process_nip62(false)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to open LMDB database at {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let tombstones = crate::nostr::tombstones::Tombstones::open_lmdb(db_path).await?;
|
||||
(Arc::new(db), tombstones)
|
||||
}
|
||||
};
|
||||
|
||||
// Build relay with GRASP-01 validation
|
||||
// Clone Arc for the write policy so both relay and policy can access the database
|
||||
@@ -729,6 +942,7 @@ pub async fn create_relay(
|
||||
// Create write policy with purgatory integration
|
||||
let write_policy = Nip34WritePolicy::new(
|
||||
database.clone(),
|
||||
tombstones,
|
||||
&git_data_path,
|
||||
purgatory,
|
||||
config.clone(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod builder;
|
||||
pub mod events;
|
||||
pub mod policy;
|
||||
pub mod tombstones;
|
||||
|
||||
/// Re-export SharedDatabase for use by policy modules
|
||||
pub use builder::SharedDatabase;
|
||||
|
||||
@@ -16,9 +16,25 @@
|
||||
/// - Kind 30617 (announcement) in purgatory: entry removed, bare repo deleted from disk
|
||||
/// - Kind 30618 (state event) in purgatory: matching state event(s) removed by event ID
|
||||
/// or by (author, identifier) coordinate
|
||||
use nostr_relay_builder::prelude::{Event, WritePolicyResult};
|
||||
/// ## Main-database interaction
|
||||
///
|
||||
/// Because the relay now runs the LMDB backend with `process_nip09(false)`,
|
||||
/// ngit-grasp owns NIP-09 entirely. In addition to evicting purgatory entries,
|
||||
/// the handler:
|
||||
/// - hard-deletes the targeted events from the main database (the backend used
|
||||
/// to do this for us), and
|
||||
/// - records the deletion request in the persistent [`Tombstones`] store so
|
||||
/// re-submission of the deleted event/coordinate stays rejected across
|
||||
/// restarts (the resubmission gate in `builder.rs` consults this store).
|
||||
///
|
||||
/// Author ownership is enforced before any main-DB deletion or tombstone
|
||||
/// recording: only the original author may delete their event.
|
||||
use nostr_relay_builder::prelude::{
|
||||
Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, WritePolicyResult,
|
||||
};
|
||||
|
||||
use super::PolicyContext;
|
||||
use crate::nostr::policy::reject_invalid;
|
||||
|
||||
/// Policy for handling NIP-09 event deletion requests
|
||||
#[derive(Clone)]
|
||||
@@ -33,21 +49,146 @@ impl DeletionPolicy {
|
||||
|
||||
/// Process a kind 5 (EventDeletion) event.
|
||||
///
|
||||
/// Checks whether the deletion request targets any purgatory announcements
|
||||
/// and removes them if so. The deletion event itself is always accepted
|
||||
/// (relays should store deletion requests per NIP-09).
|
||||
/// Removes any targeted purgatory entries, hard-deletes targeted events from
|
||||
/// the main database, and records the deletion in the persistent tombstone
|
||||
/// store so the deletion survives restarts and re-submission is blocked.
|
||||
///
|
||||
/// Only the event author can delete their own events — this is enforced by
|
||||
/// checking that the purgatory entry's owner matches `event.pubkey`.
|
||||
/// The deletion event itself is accepted (and therefore stored) per NIP-09 —
|
||||
/// unless it is an *invalid* delete (it references an event owned by a
|
||||
/// different author), in which case it is rejected, matching the LMDB
|
||||
/// backend's historical `InvalidDelete` behaviour.
|
||||
///
|
||||
/// 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.
|
||||
pub async fn handle(&self, event: &Event) -> WritePolicyResult {
|
||||
// Process purgatory removals synchronously (no async needed)
|
||||
// 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
|
||||
// `invalid`). `a`-tag author mismatches are simply ignored (the
|
||||
// coordinate pubkey is part of the tag, so a mismatch is a no-op rather
|
||||
// than an attack signal).
|
||||
for id in Self::e_tag_ids(event) {
|
||||
match self.ctx.database.event_by_id(&id).await {
|
||||
Ok(Some(target)) if target.pubkey != event.pubkey => {
|
||||
tracing::warn!(
|
||||
deleter = %event.pubkey.to_hex(),
|
||||
target_author = %target.pubkey.to_hex(),
|
||||
target_id = %id.to_hex(),
|
||||
"Rejected invalid NIP-09 deletion: target authored by another pubkey"
|
||||
);
|
||||
return reject_invalid("cannot delete event authored by another pubkey");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Database lookup failed during deletion validation");
|
||||
return super::reject_error(format!("internal error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process purgatory removals (synchronous, in-memory).
|
||||
self.remove_purgatory_targets(event);
|
||||
|
||||
// Always accept the deletion event itself so it is stored and
|
||||
// can prevent re-acceptance of the deleted event in the future.
|
||||
// Hard-delete targeted events from the main database.
|
||||
self.delete_main_db_targets(event).await;
|
||||
|
||||
// Record the deletion so future re-submission is rejected.
|
||||
if let Err(e) = self.ctx.tombstones.record_deletion(event).await {
|
||||
tracing::error!(error = %e, "Failed to record deletion tombstone");
|
||||
return super::reject_error(format!("internal error recording deletion: {e}"));
|
||||
}
|
||||
|
||||
// Accept the deletion event itself so it is stored.
|
||||
WritePolicyResult::Accept
|
||||
}
|
||||
|
||||
/// Extract all event-id (`e` tag) targets from a deletion event.
|
||||
fn e_tag_ids(event: &Event) -> Vec<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let v = tag.as_slice();
|
||||
if v.len() >= 2 && v[0] == "e" {
|
||||
EventId::from_hex(&v[1]).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hard-delete the targeted events from the main database.
|
||||
///
|
||||
/// Handles both `e`-tag (by event id) and `a`-tag (by coordinate) targets.
|
||||
/// Authorship for `e`-tag targets was validated in [`handle`]; `a`-tag
|
||||
/// coordinates carry their own pubkey which is matched against the deleter.
|
||||
async fn delete_main_db_targets(&self, event: &Event) {
|
||||
// `e` tags: delete by event id.
|
||||
let ids = Self::e_tag_ids(event);
|
||||
if !ids.is_empty() {
|
||||
let filter = Filter::new().ids(ids);
|
||||
if let Err(e) = self.ctx.database.delete(filter).await {
|
||||
tracing::warn!(error = %e, "Failed to delete events by id during NIP-09 deletion");
|
||||
}
|
||||
}
|
||||
|
||||
// `a` tags: delete matching addressable/replaceable events up to the
|
||||
// deletion's created_at.
|
||||
for tag in event.tags.iter() {
|
||||
let v = tag.as_slice();
|
||||
if v.len() < 2 || v[0] != "a" {
|
||||
continue;
|
||||
}
|
||||
self.delete_coordinate_from_main_db(&event.pubkey, &v[1], event.created_at)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete events matching an `a`-tag coordinate from the main database.
|
||||
async fn delete_coordinate_from_main_db(
|
||||
&self,
|
||||
author: &PublicKey,
|
||||
coordinate: &str,
|
||||
deletion_created_at: nostr_relay_builder::prelude::Timestamp,
|
||||
) {
|
||||
// coordinate: `<kind>:<pubkey>:<d-identifier>`
|
||||
let parts: Vec<&str> = coordinate.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return;
|
||||
}
|
||||
let Ok(kind_num) = parts[0].parse::<u16>() else {
|
||||
return;
|
||||
};
|
||||
let coord_pubkey_hex = parts[1];
|
||||
let identifier = parts[2];
|
||||
|
||||
// The coordinate pubkey must match the deletion author.
|
||||
if coord_pubkey_hex != author.to_hex() {
|
||||
return;
|
||||
}
|
||||
|
||||
let kind = Kind::from(kind_num);
|
||||
let mut filter = Filter::new().kind(kind).author(*author);
|
||||
if kind.is_addressable() {
|
||||
filter = filter.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::D),
|
||||
identifier.to_string(),
|
||||
);
|
||||
}
|
||||
// Per NIP-09, only versions up to the deletion's created_at are deleted.
|
||||
filter = filter.until(deletion_created_at);
|
||||
|
||||
if let Err(e) = self.ctx.database.delete(filter).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
coordinate = %coordinate,
|
||||
"Failed to delete events by coordinate during NIP-09 deletion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove any purgatory entries targeted by this deletion event.
|
||||
///
|
||||
/// Handles both reference styles from NIP-09:
|
||||
|
||||
@@ -29,6 +29,7 @@ use super::SharedDatabase;
|
||||
#[cfg(test)]
|
||||
use crate::grasp06::receive::new_repo_init_locks;
|
||||
use crate::grasp06::receive::RepoInitLocks;
|
||||
use crate::nostr::tombstones::Tombstones;
|
||||
use crate::purgatory::Purgatory;
|
||||
use nostr_relay_builder::LocalRelay;
|
||||
use std::sync::Arc;
|
||||
@@ -38,6 +39,14 @@ use std::sync::Arc;
|
||||
pub struct PolicyContext {
|
||||
pub domain: String,
|
||||
pub database: SharedDatabase,
|
||||
/// Persistent record of NIP-09 deletions and NIP-62 vanish requests.
|
||||
///
|
||||
/// Since the LMDB backend no longer auto-processes NIP-09/NIP-62
|
||||
/// (`process_nip09(false)` / `process_nip62(false)`), ngit-grasp owns the
|
||||
/// deletion/vanish bookkeeping. This store persists deletion/vanish requests
|
||||
/// so re-submission of a deleted event (or an event from a vanished pubkey)
|
||||
/// is still rejected, surviving restarts.
|
||||
pub tombstones: Tombstones,
|
||||
pub git_data_path: std::path::PathBuf,
|
||||
pub purgatory: Arc<Purgatory>,
|
||||
/// Local relay for notifying WebSocket subscribers (set after relay creation)
|
||||
@@ -54,6 +63,7 @@ impl PolicyContext {
|
||||
pub fn new(
|
||||
domain: impl Into<String>,
|
||||
database: SharedDatabase,
|
||||
tombstones: Tombstones,
|
||||
git_data_path: impl Into<std::path::PathBuf>,
|
||||
purgatory: Arc<Purgatory>,
|
||||
config: crate::config::Config,
|
||||
@@ -62,6 +72,7 @@ impl PolicyContext {
|
||||
Self {
|
||||
domain: domain.into(),
|
||||
database,
|
||||
tombstones,
|
||||
git_data_path: git_data_path.into(),
|
||||
purgatory,
|
||||
local_relay: Arc::new(std::sync::RwLock::new(None)),
|
||||
@@ -84,6 +95,7 @@ impl PolicyContext {
|
||||
Self::new(
|
||||
domain,
|
||||
database,
|
||||
Tombstones::in_memory(),
|
||||
git_data_path,
|
||||
purgatory,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Persistent tombstone store for NIP-09 (deletion) and NIP-62 (request to vanish).
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! Up to and including the move to rust-nostr 0.45, ngit-grasp relied on the
|
||||
//! LMDB backend's *automatic* NIP-09 / NIP-62 processing
|
||||
//! (`NostrLmdb` defaults `process_nip09 = true`, `process_nip62 = true`).
|
||||
//! That backend behaviour did two things for us:
|
||||
//!
|
||||
//! 1. **Removed deleted/vanished events from the main DB** when a kind-5 or a
|
||||
//! valid kind-62 arrived.
|
||||
//! 2. **Blocked re-submission** of a genuinely-deleted event (or an event from a
|
||||
//! vanished pubkey) — the relay-builder's `check_id` gate consulted the
|
||||
//! backend's internal `deleted-ids` / `deleted-coordinates` /
|
||||
//! `vanished-public-keys` LMDB tables and replied "this event is deleted".
|
||||
//!
|
||||
//! We disable the backend's automatic processing
|
||||
//! (`process_nip09(false)`, `process_nip62(false)`) and implement NIP-09 /
|
||||
//! NIP-62 ourselves, so we can apply our own customisations in the future. The
|
||||
//! backend's internal tombstone tables are not part of the public
|
||||
//! `NostrDatabase` API, so we cannot read or write them. Instead we keep our own
|
||||
//! persistent record here.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! Rather than inventing a bespoke key-value schema, we persist the **deletion
|
||||
//! and vanish request events themselves** (kind 5 and kind 62) in a dedicated
|
||||
//! nostr database and derive the deleted/vanished state by querying it:
|
||||
//!
|
||||
//! - a kind-5 with an `e` tag → that event id is deleted
|
||||
//! - a kind-5 with an `a` tag → that coordinate is deleted up to the kind-5's
|
||||
//! `created_at`
|
||||
//! - a kind-62 by pubkey `P` → pubkey `P` is vanished
|
||||
//!
|
||||
//! The store uses the same backend family as the main relay database (a separate
|
||||
//! [`NostrLmdb`] file for the LMDB backend, or [`MemoryDatabase`] for the memory
|
||||
//! backend) so it survives restarts exactly like the main database did before.
|
||||
//!
|
||||
//! Author validation (only the event's author may delete it; only a pubkey may
|
||||
//! request its own vanish) is enforced by the caller *before* recording, so any
|
||||
//! request stored here is already authoritative.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr_lmdb::NostrLmdb;
|
||||
use nostr_memory::MemoryDatabase;
|
||||
use nostr_relay_builder::prelude::{
|
||||
Alphabet, Event, EventId, Filter, Kind, NostrDatabase, PublicKey, SingleLetterTag, Timestamp,
|
||||
};
|
||||
|
||||
/// Directory name (under `relay_data_path`) for the LMDB tombstone database.
|
||||
const TOMBSTONE_DIR: &str = "tombstones";
|
||||
|
||||
/// Persistent record of NIP-09 deletions and NIP-62 vanish requests.
|
||||
///
|
||||
/// Backed by its own nostr database so the deleted/vanished state survives
|
||||
/// relay restarts. See the module docs for the rationale.
|
||||
#[derive(Clone)]
|
||||
pub struct Tombstones {
|
||||
db: Arc<dyn NostrDatabase>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Tombstones {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Tombstones").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tombstones {
|
||||
/// Open a persistent (LMDB) tombstone store under `relay_data_path`.
|
||||
///
|
||||
/// The database lives at `<relay_data_path>/tombstones`. NIP-09 / NIP-62
|
||||
/// auto-processing is explicitly disabled on this database: it is used purely
|
||||
/// as a raw, append-only record of deletion/vanish request events.
|
||||
pub async fn open_lmdb(relay_data_path: &Path) -> anyhow::Result<Self> {
|
||||
let path = relay_data_path.join(TOMBSTONE_DIR);
|
||||
std::fs::create_dir_all(&path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create tombstone directory {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let db = NostrLmdb::builder(&path)
|
||||
// We store deletion/vanish requests verbatim and interpret them
|
||||
// ourselves; the backend must not re-interpret them.
|
||||
.process_nip09(false)
|
||||
.process_nip62(false)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to open tombstone LMDB at {}: {}", path.display(), e)
|
||||
})?;
|
||||
|
||||
Ok(Self { db: Arc::new(db) })
|
||||
}
|
||||
|
||||
/// Create an in-memory tombstone store (used with the memory backend and in
|
||||
/// tests). Not persistent.
|
||||
pub fn in_memory() -> Self {
|
||||
Self {
|
||||
db: Arc::new(MemoryDatabase::unbounded()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a NIP-09 deletion request (kind 5).
|
||||
///
|
||||
/// The caller MUST have already validated author ownership of the targets.
|
||||
/// Storing the event makes the deletions durable and queryable.
|
||||
pub async fn record_deletion(&self, event: &Event) -> anyhow::Result<()> {
|
||||
debug_assert_eq!(event.kind, Kind::EventDeletion);
|
||||
self.db
|
||||
.save_event(event)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to record deletion tombstone: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a NIP-62 vanish request (kind 62).
|
||||
///
|
||||
/// The caller MUST have already validated that the request targets this relay
|
||||
/// and is signed by the pubkey being vanished.
|
||||
pub async fn record_vanish(&self, event: &Event) -> anyhow::Result<()> {
|
||||
debug_assert_eq!(event.kind, Kind::RequestToVanish);
|
||||
self.db
|
||||
.save_event(event)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to record vanish tombstone: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Has this event id been deleted by a recorded kind-5 (`e` tag)?
|
||||
///
|
||||
/// Mirrors the backend's `is_deleted` check that previously fed the
|
||||
/// relay-builder `check_id` gate.
|
||||
pub async fn is_event_deleted(&self, id: &EventId) -> bool {
|
||||
// Any stored kind-5 carrying this id in an `e` tag means it was deleted.
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::EventDeletion)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::E), id.to_hex());
|
||||
match self.db.query(filter).await {
|
||||
Ok(events) => !events.is_empty(),
|
||||
Err(e) => {
|
||||
// Fail secure: if we cannot determine deletion status, do not
|
||||
// claim the event is deleted (so we don't reject legitimate
|
||||
// events), but log loudly.
|
||||
tracing::error!(error = %e, "Tombstone query failed for is_event_deleted");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this addressable/replaceable coordinate been deleted at or after
|
||||
/// `event_created_at`?
|
||||
///
|
||||
/// Per NIP-09, an `a`-tag deletion deletes all versions of the coordinate up
|
||||
/// to the deletion request's `created_at`. So a candidate event is blocked
|
||||
/// only if a recorded deletion for the same coordinate has
|
||||
/// `deletion.created_at >= event_created_at`.
|
||||
pub async fn is_coordinate_deleted(
|
||||
&self,
|
||||
coordinate: &str,
|
||||
event_created_at: Timestamp,
|
||||
) -> bool {
|
||||
let filter = Filter::new().kind(Kind::EventDeletion).custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::A),
|
||||
coordinate.to_string(),
|
||||
);
|
||||
match self.db.query(filter).await {
|
||||
Ok(events) => events
|
||||
.iter()
|
||||
.any(|deletion| deletion.created_at >= event_created_at),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Tombstone query failed for is_coordinate_deleted");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this pubkey requested to vanish from this relay (recorded kind-62)?
|
||||
///
|
||||
/// Mirrors the backend's `is_pubkey_vanished` check.
|
||||
pub async fn is_pubkey_vanished(&self, pubkey: &PublicKey) -> bool {
|
||||
let filter = Filter::new().kind(Kind::RequestToVanish).author(*pubkey);
|
||||
match self.db.query(filter).await {
|
||||
Ok(events) => !events.is_empty(),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Tombstone query failed for is_pubkey_vanished");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_relay_builder::prelude::*;
|
||||
|
||||
fn deletion_by_event(keys: &Keys, target: EventId) -> Event {
|
||||
EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![Tag::event(target)])
|
||||
.finalize(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_and_detects_event_deletion() {
|
||||
let store = Tombstones::in_memory();
|
||||
let keys = Keys::generate();
|
||||
let target = EventId::all_zeros();
|
||||
|
||||
assert!(!store.is_event_deleted(&target).await);
|
||||
|
||||
let deletion = deletion_by_event(&keys, target);
|
||||
store.record_deletion(&deletion).await.unwrap();
|
||||
|
||||
assert!(store.is_event_deleted(&target).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinate_deletion_respects_created_at() {
|
||||
let store = Tombstones::in_memory();
|
||||
let keys = Keys::generate();
|
||||
let coord = format!("30618:{}:my-repo", keys.public_key().to_hex());
|
||||
|
||||
// Deletion at t=1000
|
||||
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![Tag::custom("a", vec![coord.clone()])])
|
||||
.custom_created_at(Timestamp::from_secs(1000))
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
store.record_deletion(&deletion).await.unwrap();
|
||||
|
||||
// An event created at t<=1000 is deleted; t>1000 is not.
|
||||
assert!(
|
||||
store
|
||||
.is_coordinate_deleted(&coord, Timestamp::from_secs(1000))
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_coordinate_deleted(&coord, Timestamp::from_secs(500))
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.is_coordinate_deleted(&coord, Timestamp::from_secs(1500))
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_pubkey_vanish() {
|
||||
let store = Tombstones::in_memory();
|
||||
let keys = Keys::generate();
|
||||
let other = Keys::generate();
|
||||
|
||||
assert!(!store.is_pubkey_vanished(&keys.public_key()).await);
|
||||
|
||||
let vanish = EventBuilder::new(Kind::RequestToVanish, "")
|
||||
.tags(vec![Tag::custom("relay", vec!["ALL_RELAYS".to_string()])])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
store.record_vanish(&vanish).await.unwrap();
|
||||
|
||||
assert!(store.is_pubkey_vanished(&keys.public_key()).await);
|
||||
assert!(!store.is_pubkey_vanished(&other.public_key()).await);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user