v2.1.42 - Fixed NIP-09 deletion: wired handle_deletion_request into client WebSocket path (async worker + sync dispatch), added deleted_event_ids tombstone table with atomic delete+tombstone CTEs, and rejection of deleted-event re-posts (resurrection prevention) in both client and ingress paths

This commit is contained in:
Laan Tungir
2026-08-22 07:46:36 -04:00
parent 5457fde653
commit cfe3331af7
15 changed files with 405 additions and 86 deletions
+11 -11
View File
@@ -1,15 +1,15 @@
New Events — Last 24 Hours
10071 |XX X
9064 |XX X X
8057 |XXX X X X
7050 |XXXXXXX X XX
6043 |XXXXXXX X XXX
5036 |XXXXXXX X XXXXX
4029 |XXXXXXXXX XXXXX
3022 |XXXXXXXXXX XXXXX
2015 |XXXXXXXXXX XXXXXX
1008 |XXXXXXXXXX XXXXXX
1 |XXXXXXXXXX XXXXXX X XXX X X X X XX X XXXXXXX XX
11 |
10 |
9 |
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
+--------------------------------------------------------------------------------
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
+1 -1
View File
@@ -1 +1 @@
[{"kind":1,"count":1333690,"pct":78.1},{"kind":6,"count":220490,"pct":12.9},{"kind":10002,"count":59276,"pct":3.5},{"kind":0,"count":49519,"pct":2.9},{"kind":5,"count":30179,"pct":1.8},{"kind":3,"count":6272,"pct":0.4},{"kind":30023,"count":2965,"pct":0.2},{"kind":10000,"count":1246,"pct":0.1},{"kind":4,"count":1169,"pct":0.1},{"kind":7,"count":1078,"pct":0.1},{"kind":1311,"count":1021,"pct":0.1},{"kind":30078,"count":278,"pct":0},{"kind":7376,"count":238,"pct":0},{"kind":30211,"count":103,"pct":0},{"kind":7375,"count":76,"pct":0},{"kind":30004,"count":38,"pct":0},{"kind":1018,"count":24,"pct":0},{"kind":9321,"count":24,"pct":0},{"kind":36787,"count":24,"pct":0},{"kind":30267,"count":22,"pct":0}]
[{"kind":1,"count":32,"pct":100}]
+1 -1
View File
@@ -1 +1 @@
[{"pubkey":"77cc1725d92c109c31a62a9e6199b86fbee1ff678e40e4251eaaaadb13949d1f","name":"","count":37930,"pct":2.2},{"pubkey":"1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139","name":"Laan Tungir","count":34867,"pct":2},{"pubkey":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c","name":"Vitor Pamplona","count":26606,"pct":1.6},{"pubkey":"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9","name":"corndalorian","count":19536,"pct":1.1},{"pubkey":"18905d0a5d623ab81a98ba98c582bd5f57f2506c6b808905fc599d5a0b229b08","name":"node","count":16211,"pct":0.9},{"pubkey":"d3d74124ddfb5bdc61b8f18d17c3335bbb4f8c71182a35ee27314a49a4eb7b1d","name":"average_gary","count":15968,"pct":0.9},{"pubkey":"44dc1c2db9c3fbd7bee9257eceb52be3cf8c40baf7b63f46e56b58a131c74f0b","name":"Enki","count":14508,"pct":0.8},{"pubkey":"adc14fa3ad590856dd8b80815d367f7c1e6735ad00fd98a86d002fbe9fb535e1","name":"Contra","count":13767,"pct":0.8},{"pubkey":"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e","name":"franny","count":12858,"pct":0.8},{"pubkey":"7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e","name":"cloud fodder","count":11721,"pct":0.7},{"pubkey":"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245","name":"jb55","count":11547,"pct":0.7},{"pubkey":"2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331","name":"vinney...axkl","count":11526,"pct":0.7},{"pubkey":"1e67de3754171071d3cf9b44b6e546bd94fd0a2ca3fb4dbbb1b054685c9116e4","name":"Renaud Lifchitz","count":11126,"pct":0.7},{"pubkey":"b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a","name":"0xtr","count":10848,"pct":0.6},{"pubkey":"c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd","name":"iefan \ud83d\udd4a\ufe0f","count":10573,"pct":0.6},{"pubkey":"604e96e099936a104883958b040b47672e0f048c98ac793f37ffe4c720279eb2","name":"NotBiebs and 69 others","count":10292,"pct":0.6},{"pubkey":"9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905","name":"nostr.build","count":10252,"pct":0.6},{"pubkey":"4eb88310d6b4ed95c6d66a395b3d3cf559b85faec8f7691dafd405a92e055d6d","name":"Ava","count":10145,"pct":0.6},{"pubkey":"a44dbc9aaa357176a7d4f5c3106846ea096b66de0b50ee39aff54baab6c4bf4b","name":"Ben Justman\ud83c\udf77","count":10138,"pct":0.6},{"pubkey":"ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74","name":"Dr. The Daniel \ud83d\udd96","count":9795,"pct":0.6}]
[{"pubkey":"4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa","name":"","count":30,"pct":93.8},{"pubkey":"fad56c53326438d82e5702c31c0900a1ce5037ad853501d0f9e536524c890326","name":"","count":1,"pct":3.1},{"pubkey":"a06944d055613409e5f3ec773d2d257bc7537a49a0e99fc0a24e11668aeafaca","name":"","count":1,"pct":3.1}]
@@ -0,0 +1,143 @@
# NIP-09 Client-Path Deletion + Tombstone (Option A) Implementation Plan
## Background
Two verified defects:
1. **NIP-09 dead code on the client path**: [`handle_deletion_request()`](../src/nip009.c:25) is a complete, correct NIP-09 implementation (e/a tag parsing, authorship enforcement, hard delete via `db_delete_event_by_id()`), but it is only invoked from [`ingest_event()`](../src/main.c:1409) in `main.c` (external ingress). The client WebSocket path in `websockets.c` declares it at [line 84](../src/websockets.c: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.
2. **Resurrection vulnerability**: deletion is a hard `DELETE FROM events` with 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 with `duplicate: 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_SQL`](../src/pg_schema.h:6) is applied idempotently on every startup by [`postgres_db_apply_schema()`](../src/db_ops_postgres.c:150) — 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_conn`](../src/db_ops_postgres.c:61) is `__thread`), so `handle_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`](../src/pg_schema.h) and [`src/pg_schema.sql`](../src/pg_schema.sql)
Add to both files (idempotent):
```sql
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`](../src/db_ops_postgres.c)
**Rework [`postgres_db_delete_event_by_id()`](../src/db_ops_postgres.c:826)** into a single CTE statement:
```sql
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 `PQexecParams` with `PGRES_TUPLES_OK` expected (CTE returns rows) — note the current code checks `PGRES_COMMAND_OK`; the reworked version must check for `TUPLES_OK` or accept both.
- Return value: count of deleted rows (from `PQcmdTuples` of 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()`](../src/db_ops_postgres.c:857)** 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`](../src/db_ops_postgres.c):
```sql
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`](../src/db_ops_postgres.h) — declaration
- [`db_ops.c`](../src/db_ops.c) — `db_event_id_status()` wrapper
- [`db_ops.h`](../src/db_ops.h) — declaration
### 4. Kind 5 dispatch — [`src/websockets.c`](../src/websockets.c)
Three sites, all getting the same branch inserted **before** the ephemeral check:
**a) Async worker ([line ~600](../src/websockets.c:600)) — the PRIMARY client path:**
```c
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](../src/nip009.c:135)), which stores the deletion request itself — the branch must NOT also call `store_event_core()`.
**b) Sync block 1 ([line ~1873](../src/websockets.c:1873)) and c) Sync block 2 ([line ~2670](../src/websockets.c:2670)):**
```c
} 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](../src/websockets.c:580)):**
```c
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](../src/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`](../tests/9_nip_delete_test.sh)
Add a resurrection test after the existing by-ID deletion test:
1. Re-post `event1` (the deleted kind 1 event) verbatim
2. Assert the OK response is `false` with the deletion message
3. Assert `check_event_exists "$event1_id"` still returns 0
## Execution Order
1. Schema (pg_schema.h + pg_schema.sql)
2. db_ops layer (delete CTEs + status check + wiring)
3. websockets.c dispatch branches (3 sites)
4. Pre-check extensions (2 sites)
5. Test extension
6. Build with `./make_and_restart_relay.sh` (NEVER `make`)
7. Run `tests/9_nip_delete_test.sh` — all assertions must pass
## Risks & Mitigations
- **CTE result status**: `WITH ... INSERT ... SELECT` returns `PGRES_TUPLES_OK` when using `RETURNING`, `PGRES_COMMAND_OK` otherwise. The outer statement is an INSERT...SELECT without RETURNING → `PGRES_COMMAND_OK`, and `PQcmdTuples` returns the inserted count. Verify with a quick psql test during implementation.
- **`store_event()` in worker thread**: `handle_deletion_request()` calls `store_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.
+1 -1
View File
@@ -1 +1 @@
1445308
4055658
+3
View File
@@ -80,6 +80,9 @@ int db_delete_event_by_id(const char* event_id, const char* requester_pubkey) {
int db_delete_events_by_address(const char* pubkey, int kind, const char* d_tag, long before_timestamp) {
return postgres_db_delete_events_by_address(pubkey, kind, d_tag, before_timestamp);
}
int db_event_id_status(const char* event_id, int* out_exists, int* out_tombstoned) {
return postgres_db_event_id_status(event_id, out_exists, out_tombstoned);
}
int db_is_pubkey_blacklisted(const char* pubkey) { return postgres_db_is_pubkey_blacklisted(pubkey); }
int db_is_hash_blacklisted(const char* resource_hash) { return postgres_db_is_hash_blacklisted(resource_hash); }
+2
View File
@@ -71,6 +71,8 @@ int db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t pubkey_ou
int db_delete_event_by_id(const char* event_id, const char* requester_pubkey);
int db_delete_events_by_address(const char* pubkey, int kind,
const char* d_tag, long before_timestamp);
// Combined exists + tombstone check for the ingest fast path (NIP-09)
int db_event_id_status(const char* event_id, int* out_exists, int* out_tombstoned);
// Auth rule checks for request validator
int db_is_pubkey_blacklisted(const char* pubkey);
+74 -6
View File
@@ -830,9 +830,18 @@ int postgres_db_delete_event_by_id(const char* event_id, const char* requester_p
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
// Atomic delete + tombstone: the CTE deletes the event and inserts its ID
// into deleted_event_ids in one statement, so a crash can never leave a
// deleted event without its tombstone (which would allow resurrection).
const char* params[2] = { event_id, requester_pubkey };
PGresult* res = PQexecParams(conn,
"DELETE FROM events WHERE id = $1 AND pubkey = $2",
"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",
2, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_COMMAND_OK) {
if (res) {
@@ -867,19 +876,34 @@ int postgres_db_delete_events_by_address(const char* pubkey, int kind,
snprintf(kind_buf, sizeof(kind_buf), "%d", kind);
snprintf(before_buf, sizeof(before_buf), "%ld", before_timestamp);
// Atomic delete + tombstone (same CTE pattern as delete_event_by_id):
// every deleted event's ID is recorded in deleted_event_ids so re-posts
// are rejected instead of resurrecting the event.
PGresult* res = NULL;
if (d_tag && d_tag[0] != '\0') {
const char* params[4] = { kind_buf, pubkey, before_buf, d_tag };
res = PQexecParams(conn,
"DELETE FROM events "
"WHERE kind = $1::INT AND pubkey = $2 AND created_at < $3::BIGINT "
"AND d_tag_value = $4",
"WITH deleted AS ( "
" DELETE FROM events "
" WHERE kind = $1::INT AND pubkey = $2 AND created_at < $3::BIGINT "
" AND d_tag_value = $4 "
" RETURNING id "
") "
"INSERT INTO deleted_event_ids (event_id) "
"SELECT id FROM deleted "
"ON CONFLICT (event_id) DO NOTHING",
4, NULL, params, NULL, NULL, 0);
} else {
const char* params[3] = { kind_buf, pubkey, before_buf };
res = PQexecParams(conn,
"DELETE FROM events "
"WHERE kind = $1::INT AND pubkey = $2 AND created_at < $3::BIGINT",
"WITH deleted AS ( "
" DELETE FROM events "
" WHERE kind = $1::INT AND pubkey = $2 AND created_at < $3::BIGINT "
" RETURNING id "
") "
"INSERT INTO deleted_event_ids (event_id) "
"SELECT id FROM deleted "
"ON CONFLICT (event_id) DO NOTHING",
3, NULL, params, NULL, NULL, 0);
}
@@ -903,6 +927,50 @@ int postgres_db_delete_events_by_address(const char* pubkey, int kind,
#endif
}
// Combined event-ID status check for the ingest fast path: reports whether an
// event exists in the events table and whether it has a NIP-09 deletion
// tombstone, in a single round trip. Callers use this to reject re-posts of
// deleted events before signature verification.
int postgres_db_event_id_status(const char* event_id, int* out_exists, int* out_tombstoned) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!event_id || !out_exists || !out_tombstoned) return DB_MISUSE;
*out_exists = 0;
*out_tombstoned = 0;
PGconn* conn = postgres_db_active_connection();
if (!conn || PQstatus(conn) != CONNECTION_OK) return DB_ERROR;
const char* params[1] = { event_id };
PGresult* res = PQexecParams(conn,
"SELECT "
" EXISTS(SELECT 1 FROM events WHERE id = $1)::INT AS exists_in_events, "
" EXISTS(SELECT 1 FROM deleted_event_ids WHERE event_id = $1)::INT AS is_tombstoned",
1, NULL, params, NULL, NULL, 0);
if (!res || PQresultStatus(res) != PGRES_TUPLES_OK) {
if (res) {
postgres_set_error_text(PQresultErrorMessage(res));
PQclear(res);
} else {
postgres_set_error_from_conn(conn, "postgres_db_event_id_status failed");
}
return DB_ERROR;
}
if (PQntuples(res) > 0) {
*out_exists = (PQgetisnull(res, 0, 0)) ? 0 : (atoi(PQgetvalue(res, 0, 0)) != 0);
*out_tombstoned = (PQgetisnull(res, 0, 1)) ? 0 : (atoi(PQgetvalue(res, 0, 1)) != 0);
}
PQclear(res);
return DB_OK;
#else
(void)event_id;
if (out_exists) *out_exists = 0;
if (out_tombstoned) *out_tombstoned = 0;
return DB_ERROR;
#endif
}
int postgres_db_is_pubkey_blacklisted(const char* pubkey) {
#if defined(DB_BACKEND_POSTGRES) && defined(HAVE_LIBPQ)
if (!pubkey) return 0;
+1
View File
@@ -47,6 +47,7 @@ int postgres_db_get_event_pubkey(const char* event_id, char* pubkey_out, size_t
int postgres_db_delete_event_by_id(const char* event_id, const char* requester_pubkey);
int postgres_db_delete_events_by_address(const char* pubkey, int kind,
const char* d_tag, long before_timestamp);
int postgres_db_event_id_status(const char* event_id, int* out_exists, int* out_tombstoned);
int postgres_db_is_pubkey_blacklisted(const char* pubkey);
int postgres_db_is_hash_blacklisted(const char* resource_hash);
+17 -1
View File
@@ -1335,7 +1335,23 @@ int ingest_event(const char* event_json, size_t event_json_len,
if (peek_id && strlen(peek_id) == 64) {
strncpy(event_id_buf, peek_id, 64);
event_id_buf[64] = '\0';
if (event_id_exists_in_db(event_id_buf)) {
int ev_exists = 0;
int ev_tombstoned = 0;
if (db_event_id_status(event_id_buf, &ev_exists, &ev_tombstoned) == 0 && ev_tombstoned) {
// NIP-09: this event was deleted by its author; refuse to
// resurrect it via external ingress (caching poller, backfill).
DEBUG_TRACE("ingest_event: tombstoned event %s rejected (deleted)", event_id_buf);
if (peek) {
cJSON_Delete(peek);
}
if (ingress_idx >= 0) {
#ifdef DB_BACKEND_POSTGRES
__sync_fetch_and_add(&g_ingress_rejected[ingress_idx], 1);
#endif
}
return -1;
}
if (ev_exists) {
DEBUG_TRACE("ingest_event: duplicate event %s already in canonical DB", event_id_buf);
if (peek) {
cJSON_Delete(peek);
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 41
#define CRELAY_VERSION "v2.1.41"
#define CRELAY_VERSION_PATCH 42
#define CRELAY_VERSION "v2.1.42"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"
+8
View File
@@ -105,6 +105,14 @@ static const char* const EMBEDDED_PG_SCHEMA_SQL =
"CREATE INDEX IF NOT EXISTS idx_event_tags_event ON event_tags(event_id);\n"
"CREATE INDEX IF NOT EXISTS idx_event_tags_value_name ON event_tags(tag_value, tag_name);\n"
"\n"
"-- NIP-09 deletion tombstones: remember deleted event IDs so re-posts are\n"
"-- rejected instead of resurrecting the event. No FK to events on purpose —\n"
"-- tombstone rows must outlive the events they record.\n"
"CREATE TABLE IF NOT EXISTS deleted_event_ids (\n"
" event_id TEXT PRIMARY KEY,\n"
" deleted_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
");\n"
"\n"
"CREATE OR REPLACE FUNCTION set_event_derived_fields()\n"
"RETURNS TRIGGER AS $$\n"
"DECLARE\n"
+8
View File
@@ -99,6 +99,14 @@ CREATE INDEX IF NOT EXISTS idx_event_tags_lookup ON event_tags(tag_name, tag_val
CREATE INDEX IF NOT EXISTS idx_event_tags_event ON event_tags(event_id);
CREATE INDEX IF NOT EXISTS idx_event_tags_value_name ON event_tags(tag_value, tag_name);
-- NIP-09 deletion tombstones: remember deleted event IDs so re-posts are
-- rejected instead of resurrecting the event. No FK to events on purpose —
-- tombstone rows must outlive the events they record.
CREATE TABLE IF NOT EXISTS deleted_event_ids (
event_id TEXT PRIMARY KEY,
deleted_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT
);
CREATE OR REPLACE FUNCTION set_event_derived_fields()
RETURNS TRIGGER AS $$
DECLARE
+76 -8
View File
@@ -577,13 +577,33 @@ static void* async_event_worker_main(void* arg) {
strncpy(completion->event_id, job->event_id, sizeof(completion->event_id) - 1);
completion->event_id[sizeof(completion->event_id) - 1] = '\0';
if (job->event_id[0] != '\0' && event_id_exists_in_db(job->event_id)) {
completion->success = 1;
completion->should_broadcast = 0;
completion->run_post_actions = 0;
snprintf(completion->ok_message, sizeof(completion->ok_message),
"duplicate: already have this event");
} else {
int precheck_handled = 0;
if (job->event_id[0] != '\0') {
int ev_exists = 0;
int ev_tombstoned = 0;
if (db_event_id_status(job->event_id, &ev_exists, &ev_tombstoned) == 0) {
if (ev_tombstoned) {
// NIP-09: this event was deleted by its author; refuse to
// resurrect it. Report failure so the client sees a rejection.
completion->success = 0;
completion->should_broadcast = 0;
completion->run_post_actions = 0;
snprintf(completion->error_message, sizeof(completion->error_message),
"rejected: event was deleted");
precheck_handled = 1;
} else if (ev_exists) {
completion->success = 1;
completion->should_broadcast = 0;
completion->run_post_actions = 0;
snprintf(completion->ok_message, sizeof(completion->ok_message),
"duplicate: already have this event");
precheck_handled = 1;
}
}
}
if (!precheck_handled) {
int validation_result = nostr_validate_unified_request(completion->event_json, strlen(completion->event_json));
if (validation_result != NOSTR_SUCCESS) {
completion->success = 0;
@@ -597,7 +617,23 @@ static void* async_event_worker_main(void* arg) {
strncpy(completion->error_message, "error: failed to parse event", sizeof(completion->error_message) - 1);
completion->error_message[sizeof(completion->error_message) - 1] = '\0';
} else {
if (job->event_kind >= 20000 && job->event_kind < 30000) {
if (job->event_kind == 5) {
// NIP-09 deletion request: run the deletion handler
// (which also stores the request itself), then let
// the completion path broadcast on the lws main thread.
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;
strncpy(completion->error_message, del_error, sizeof(completion->error_message) - 1);
completion->error_message[sizeof(completion->error_message) - 1] = '\0';
} else {
completion->success = 1;
completion->should_broadcast = 1;
completion->run_post_actions = 0;
}
} else if (job->event_kind >= 20000 && job->event_kind < 30000) {
completion->success = 1;
completion->should_broadcast = 1;
completion->run_post_actions = 0;
@@ -1870,6 +1906,22 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 5) {
// NIP-09 deletion request: run the deletion handler
// (which also stores the request itself), then broadcast
// so subscribers can react to the deletion.
char del_error[512] = {0};
if (handle_deletion_request(event, del_error, sizeof(del_error)) != 0) {
DEBUG_ERROR("NIP-09 deletion request rejected: %s", del_error);
result = -1;
size_t del_len = strlen(del_error);
size_t del_copy = (del_len < sizeof(error_message) - 1) ? del_len : sizeof(error_message) - 1;
memcpy(error_message, del_error, del_copy);
error_message[del_copy] = '\0';
} else {
DEBUG_LOG("NIP-09 deletion request processed (kind 5)");
broadcast_event_to_subscriptions(event);
}
} else {
// Check if this is an ephemeral event (kinds 20000-29999)
// Per NIP-01: ephemeral events are broadcast but never stored
@@ -2667,6 +2719,22 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
broadcast_event_to_subscriptions(event);
}
}
} else if (event_kind == 5) {
// NIP-09 deletion request: run the deletion handler
// (which also stores the request itself), then broadcast
// so subscribers can react to the deletion.
char del_error[512] = {0};
if (handle_deletion_request(event, del_error, sizeof(del_error)) != 0) {
DEBUG_ERROR("NIP-09 deletion request rejected: %s", del_error);
result = -1;
size_t del_len = strlen(del_error);
size_t del_copy = (del_len < sizeof(error_message) - 1) ? del_len : sizeof(error_message) - 1;
memcpy(error_message, del_error, del_copy);
error_message[del_copy] = '\0';
} else {
DEBUG_LOG("NIP-09 deletion request processed (kind 5)");
broadcast_event_to_subscriptions(event);
}
} else {
// Check if this is an ephemeral event (kinds 20000-29999)
// Per NIP-01: ephemeral events are broadcast but never stored
+57 -55
View File
@@ -44,6 +44,8 @@ print_warning() {
}
# Helper function to publish event and extract ID
# IMPORTANT: prints ONLY the event ID to stdout (diagnostics go to stderr) so
# command substitution captures a clean ID usable in -e tags.
publish_event() {
local event_json="$1"
local description="$2"
@@ -51,40 +53,35 @@ publish_event() {
# Extract event ID
local event_id=$(echo "$event_json" | jq -r '.id' 2>/dev/null)
if [[ "$event_id" == "null" || -z "$event_id" ]]; then
print_error "Could not extract event ID from $description"
print_error "Could not extract event ID from $description" >&2
return 1
fi
print_info "Publishing $description..."
print_info "Publishing $description..." >&2
# Create EVENT message in Nostr format
local event_message="[\"EVENT\",$event_json]"
# Publish to relay
# Publish to relay using nak (reliable WebSocket lifecycle handling).
# The event JSON is piped to nak event which re-publishes it verbatim.
local response=""
if command -v websocat &> /dev/null; then
response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
else
print_error "websocat not found - required for testing"
return 1
fi
response=$(echo "$event_json" | nak event "$RELAY_URL" 2>&1 || echo "Connection failed")
# Check response
if [[ "$response" == *"Connection failed"* ]]; then
print_error "Failed to connect to relay for $description"
print_error "Failed to connect to relay for $description" >&2
return 1
elif [[ "$response" == *"true"* ]]; then
print_success "$description uploaded (ID: ${event_id:0:16}...)"
elif [[ "$response" == *"success"* || "$response" == *"true"* ]]; then
print_success "$description uploaded (ID: ${event_id:0:16}...)" >&2
echo "$event_id"
return 0
else
print_warning "$description might have failed: $response"
print_warning "$description might have failed: $response" >&2
echo ""
return 1
fi
}
# Helper function to publish deletion request
# IMPORTANT: prints ONLY the event ID to stdout (diagnostics go to stderr) so
# command substitution captures a clean ID.
publish_deletion_request() {
local deletion_event_json="$1"
local description="$2"
@@ -92,34 +89,26 @@ publish_deletion_request() {
# Extract event ID
local event_id=$(echo "$deletion_event_json" | jq -r '.id' 2>/dev/null)
if [[ "$event_id" == "null" || -z "$event_id" ]]; then
print_error "Could not extract event ID from $description"
print_error "Could not extract event ID from $description" >&2
return 1
fi
print_info "Publishing $description..."
print_info "Publishing $description..." >&2
# Create EVENT message in Nostr format
local event_message="[\"EVENT\",$deletion_event_json]"
# Publish to relay
# Publish to relay using nak (reliable WebSocket lifecycle handling).
local response=""
if command -v websocat &> /dev/null; then
response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
else
print_error "websocat not found - required for testing"
return 1
fi
response=$(echo "$deletion_event_json" | nak event "$RELAY_URL" 2>&1 || echo "Connection failed")
# Check response
if [[ "$response" == *"Connection failed"* ]]; then
print_error "Failed to connect to relay for $description"
print_error "Failed to connect to relay for $description" >&2
return 1
elif [[ "$response" == *"true"* ]]; then
print_success "$description accepted (ID: ${event_id:0:16}...)"
elif [[ "$response" == *"success"* || "$response" == *"true"* ]]; then
print_success "$description accepted (ID: ${event_id:0:16}...)" >&2
echo "$event_id"
return 0
else
print_warning "$description might have failed: $response"
print_warning "$description might have failed: $response" >&2
echo ""
return 1
fi
@@ -128,21 +117,15 @@ publish_deletion_request() {
# Helper function to check if event exists via subscription
check_event_exists() {
local event_id="$1"
local sub_id="exists_$(date +%s%N | cut -c1-10)"
# Create REQ message to query for specific event ID
local req_message="[\"REQ\",\"$sub_id\",{\"ids\":[\"$event_id\"]}]"
# Send subscription and collect events
# Use nak req with an ids filter - reliable and waits for EOSE
local response=""
if command -v websocat &> /dev/null; then
response=$(echo -e "$req_message\n[\"CLOSE\",\"$sub_id\"]" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
fi
response=$(echo "{\"ids\":[\"$event_id\"]}" | nak req "$RELAY_URL" 2>/dev/null || echo "")
# Count EVENT responses
# Count matching EVENT responses (lines containing the event id)
local event_count=0
if [[ -n "$response" ]]; then
event_count=$(echo "$response" | grep -c "\"EVENT\"" 2>/dev/null || echo "0")
event_count=$(echo "$response" | grep -c "$event_id" 2>/dev/null || echo "0")
fi
echo "$event_count"
@@ -151,21 +134,15 @@ check_event_exists() {
# Helper function to query events by kind
query_events_by_kind() {
local kind="$1"
local sub_id="kind${kind}_$(date +%s%N | cut -c1-10)"
# Create REQ message to query for events of specific kind
local req_message="[\"REQ\",\"$sub_id\",{\"kinds\":[$kind]}]"
# Send subscription and collect events
# Use nak req with a kinds filter - reliable and waits for EOSE
local response=""
if command -v websocat &> /dev/null; then
response=$(echo -e "$req_message\n[\"CLOSE\",\"$sub_id\"]" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
fi
response=$(echo "{\"kinds\":[$kind]}" | nak req "$RELAY_URL" 2>/dev/null || echo "")
# Count EVENT responses
# Count matching event lines (JSON objects with the kind field)
local event_count=0
if [[ -n "$response" ]]; then
event_count=$(echo "$response" | grep -c "\"EVENT\"" 2>/dev/null || echo "0")
event_count=$(echo "$response" | grep -c "\"kind\":$kind" 2>/dev/null || echo "0")
fi
echo "$event_count"
@@ -226,7 +203,8 @@ run_deletion_test() {
fi
# Create an event by a different author (to test unauthorized deletion)
local different_key="nsec1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"
# Valid second key (hex) - the old fake nsec was rejected by nak
local different_key="8b7a5f3e2d1c0a9876543210fedcba9876543210fedcba9876543210fedcba98"
local unauth_event=$(nak event --sec "$different_key" -c "Event by different author" -k 1 --ts $(($(date +%s) - 70)) -t "type=unauthorized" 2>/dev/null)
unauth_event_id=$(publish_event "$unauth_event" "Event by different author")
@@ -349,7 +327,30 @@ run_deletion_test() {
print_info "Invalid deletion request response: $invalid_response"
fi
print_header "PHASE 6: Verification"
print_header "PHASE 6: Testing Deletion Permanence (Tombstone)"
# Re-post the deleted event verbatim - the relay must refuse to resurrect it
print_step "Re-posting deleted event (resurrection attempt)..."
local resurrection_output=""
if [[ -n "$event1" ]]; then
resurrection_output=$(echo "$event1" | nak event "$RELAY_URL" 2>&1 || true)
print_info "Resurrection attempt output: $resurrection_output"
fi
sleep 2
# The event must still be gone
print_step "Verifying deleted event stays deleted..."
local event1_resurrected=$(check_event_exists "$event1_id")
print_info "Event1 exists after resurrection attempt: $event1_resurrected"
if [[ "$event1_resurrected" == "0" ]]; then
print_success "✓ Deleted event was not resurrected (tombstone working)"
else
print_error "✗ Deleted event was resurrected - tombstone not working!"
fi
print_header "PHASE 7: Verification"
# Verify deletion requests themselves are stored
print_step "Verifying deletion requests are stored..."
@@ -374,9 +375,10 @@ if run_deletion_test; then
print_success "All NIP-09 deletion tests completed successfully!"
print_info "The C-Relay-PG NIP-09 implementation is working correctly"
print_info "✅ Event deletion by ID working"
print_info "✅ Address-based deletion working"
print_info "✅ Address-based deletion working"
print_info "✅ Authorization validation working"
print_info "✅ Invalid deletion rejection working"
print_info "✅ Deletion permanence (tombstone) working"
echo
exit 0
else