7.7 KiB
NIP-09 Client-Path Deletion + Tombstone (Option A) Implementation Plan
Background
Two verified defects:
-
NIP-09 dead code on the client path:
handle_deletion_request()is a complete, correct NIP-09 implementation (e/a tag parsing, authorship enforcement, hard delete viadb_delete_event_by_id()), but it is only invoked fromingest_event()inmain.c(external ingress). The client WebSocket path inwebsockets.cdeclares it at line 84 with zero call sites. Kind 5 events fall into the generic regular-event branch and are merely stored + broadcast — target events are never deleted. -
Resurrection vulnerability: deletion is a hard
DELETE FROM eventswith no memory. A re-posted deleted event passes the duplicate pre-check (row gone), passes validation (signature still valid), and is re-stored + re-broadcast. Re-ingestion vectors: clients re-posting, the caching inbox poller, and custom backfill jobs.
Design Decisions
- Tombstone table (
deleted_event_ids): records every deleted event ID at deletion time. Checked during the duplicate pre-check; a tombstoned ID is treated as a duplicate and rejected withduplicate: event was deleted. - Atomicity: tombstone insert happens in the same SQL statement as the DELETE (CTE with
RETURNING), so a crash between delete and tombstone-write is impossible. - Schema delivery:
EMBEDDED_PG_SCHEMA_SQLis applied idempotently on every startup bypostgres_db_apply_schema()— no migration logic needed; just add the table to the embedded schema. - Threading: the async event worker opens its own thread-local connection (
g_thread_pg_connis__thread), sohandle_deletion_request()is safe to call from the worker thread. - Broadcast responsibility: in the async worker path, broadcast stays on the lws main thread via the existing completion mechanism (
completion->should_broadcast), matching the established pattern.
Changes
1. Schema — src/pg_schema.h and src/pg_schema.sql
Add to both files (idempotent):
CREATE TABLE IF NOT EXISTS deleted_event_ids (
event_id TEXT PRIMARY KEY,
deleted_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
No FK to events (rows must survive the event's deletion — that's the point).
2. Atomic delete + tombstone — src/db_ops_postgres.c
Rework postgres_db_delete_event_by_id() into a single CTE statement:
WITH deleted AS (
DELETE FROM events WHERE id = $1 AND pubkey = $2 RETURNING id
)
INSERT INTO deleted_event_ids (event_id)
SELECT id FROM deleted
ON CONFLICT (event_id) DO NOTHING;
- Use
PQexecParamswithPGRES_TUPLES_OKexpected (CTE returns rows) — note the current code checksPGRES_COMMAND_OK; the reworked version must check forTUPLES_OKor accept both. - Return value: count of deleted rows (from
PQcmdTuplesof the outer INSERT, or run a follow-up count). Preserve the existing contract:>0= deleted count,0= nothing deleted,<0= error.
Rework postgres_db_delete_events_by_address() with the same CTE pattern (both the d_tag and no-d_tag variants).
3. New status check — db_ops layer
Add postgres_db_event_id_status() to db_ops_postgres.c:
SELECT
EXISTS(SELECT 1 FROM events WHERE id = $1) AS exists_in_events,
EXISTS(SELECT 1 FROM deleted_event_ids WHERE event_id = $1) AS is_tombstoned;
Returns via out-params. Wire through:
db_ops_postgres.h— declarationdb_ops.c—db_event_id_status()wrapperdb_ops.h— declaration
4. Kind 5 dispatch — src/websockets.c
Three sites, all getting the same branch inserted before the ephemeral check:
a) Async worker (line ~600) — the PRIMARY client path:
if (job->event_kind == 5) {
char del_error[512] = {0};
if (handle_deletion_request(event_obj, del_error, sizeof(del_error)) != 0) {
completion->success = 0;
completion->should_broadcast = 0;
completion->run_post_actions = 0;
/* copy del_error into completion->error_message */
} else {
completion->success = 1;
completion->should_broadcast = 1;
completion->run_post_actions = 0;
}
} else if (job->event_kind >= 20000 && job->event_kind < 30000) {
...
Note: handle_deletion_request() internally calls store_event() (nip009.c:135), which stores the deletion request itself — the branch must NOT also call store_event_core().
b) Sync block 1 (line ~1873) and c) Sync block 2 (line ~2670):
} else if (event_kind == 5) {
char del_error[512] = {0};
if (handle_deletion_request(event, del_error, sizeof(del_error)) != 0) {
result = -1;
/* copy del_error into error_message */
} else {
broadcast_event_to_subscriptions(event);
}
}
5. Tombstone pre-check — two sites
a) Async worker duplicate check (websockets.c:580):
if (job->event_id[0] != '\0' && event_id_exists_in_db(job->event_id)) {
... duplicate ...
}
becomes a status check: if tombstoned → reject with duplicate: event was deleted (success=0 or duplicate-style OK with false? — decide: return OK false with message, consistent with duplicate handling but distinct message).
b) Ingress duplicate check (main.c:1338): same treatment.
Decision — response semantics for tombstoned re-posts: The cleanest Nostr-protocol behavior is ["OK", <id>, false, "duplicate: event was deleted"]. This tells clients the relay refuses to resurrect, without leaking whether the event ever existed. The existing duplicate path returns success=1 with a "duplicate" message; for tombstones we return success=0 since the event is NOT accepted. (Alternative: silently accept-and-drop; rejected as it confuses clients.)
6. Test — tests/9_nip_delete_test.sh
Add a resurrection test after the existing by-ID deletion test:
- Re-post
event1(the deleted kind 1 event) verbatim - Assert the OK response is
falsewith the deletion message - Assert
check_event_exists "$event1_id"still returns 0
Execution Order
- Schema (pg_schema.h + pg_schema.sql)
- db_ops layer (delete CTEs + status check + wiring)
- websockets.c dispatch branches (3 sites)
- Pre-check extensions (2 sites)
- Test extension
- Build with
./make_and_restart_relay.sh(NEVERmake) - Run
tests/9_nip_delete_test.sh— all assertions must pass
Risks & Mitigations
- CTE result status:
WITH ... INSERT ... SELECTreturnsPGRES_TUPLES_OKwhen usingRETURNING,PGRES_COMMAND_OKotherwise. The outer statement is an INSERT...SELECT without RETURNING →PGRES_COMMAND_OK, andPQcmdTuplesreturns the inserted count. Verify with a quick psql test during implementation. store_event()in worker thread:handle_deletion_request()callsstore_event()→store_event_post_actions()(main-thread-only per comment). For kind 5, post-actions only trigger WoT sync for admin kind 3 events — benign. Acceptable; note in code comment.- Tombstone growth: unbounded table growth. Acceptable for now (64-byte IDs); a retention policy can be added later via admin config.
- Existing DBs: schema auto-applies on startup; no manual migration.