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:
DanConwayDev
2026-06-16 21:08:44 +00:00
parent 90bd544915
commit 3aa64f088d
6 changed files with 742 additions and 36 deletions
+67 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+150 -9
View File
@@ -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:
+12
View File
@@ -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,
+272
View File
@@ -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);
}
}