diff --git a/admin/cache/chart_day.txt b/admin/cache/chart_day.txt index 74057e2..b2885b0 100644 --- a/admin/cache/chart_day.txt +++ b/admin/cache/chart_day.txt @@ -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 diff --git a/admin/cache/stats_kinds.json b/admin/cache/stats_kinds.json index 2e684d8..f6aadd5 100644 --- a/admin/cache/stats_kinds.json +++ b/admin/cache/stats_kinds.json @@ -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}] \ No newline at end of file +[{"kind":1,"count":32,"pct":100}] \ No newline at end of file diff --git a/admin/cache/stats_pubkeys.json b/admin/cache/stats_pubkeys.json index 9d6938c..1c81509 100644 --- a/admin/cache/stats_pubkeys.json +++ b/admin/cache/stats_pubkeys.json @@ -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}] \ No newline at end of file +[{"pubkey":"4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa","name":"","count":30,"pct":93.8},{"pubkey":"fad56c53326438d82e5702c31c0900a1ce5037ad853501d0f9e536524c890326","name":"","count":1,"pct":3.1},{"pubkey":"a06944d055613409e5f3ec773d2d257bc7537a49a0e99fc0a24e11668aeafaca","name":"","count":1,"pct":3.1}] \ No newline at end of file diff --git a/plans/nip09_client_deletion_and_tombstones_plan.md b/plans/nip09_client_deletion_and_tombstones_plan.md new file mode 100644 index 0000000..38f2ce4 --- /dev/null +++ b/plans/nip09_client_deletion_and_tombstones_plan.md @@ -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", , 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. diff --git a/relay.pid b/relay.pid index 66848a5..7206daf 100644 --- a/relay.pid +++ b/relay.pid @@ -1 +1 @@ -1445308 +4055658 diff --git a/src/db_ops.c b/src/db_ops.c index 755d79a..9e42415 100644 --- a/src/db_ops.c +++ b/src/db_ops.c @@ -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); } diff --git a/src/db_ops.h b/src/db_ops.h index ebf3831..0046d01 100644 --- a/src/db_ops.h +++ b/src/db_ops.h @@ -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); diff --git a/src/db_ops_postgres.c b/src/db_ops_postgres.c index f78f0c9..bd2b20c 100644 --- a/src/db_ops_postgres.c +++ b/src/db_ops_postgres.c @@ -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; diff --git a/src/db_ops_postgres.h b/src/db_ops_postgres.h index cc2d5a0..46acc0f 100644 --- a/src/db_ops_postgres.h +++ b/src/db_ops_postgres.h @@ -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); diff --git a/src/main.c b/src/main.c index 77302cc..8be5bf0 100644 --- a/src/main.c +++ b/src/main.c @@ -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); diff --git a/src/main.h b/src/main.h index dda36d1..7f770cf 100644 --- a/src/main.h +++ b/src/main.h @@ -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" diff --git a/src/pg_schema.h b/src/pg_schema.h index 3d31bbd..b5c63c6 100644 --- a/src/pg_schema.h +++ b/src/pg_schema.h @@ -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" diff --git a/src/pg_schema.sql b/src/pg_schema.sql index de49aa9..54c9118 100644 --- a/src/pg_schema.sql +++ b/src/pg_schema.sql @@ -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 diff --git a/src/websockets.c b/src/websockets.c index 8d7725b..c36d8d5 100644 --- a/src/websockets.c +++ b/src/websockets.c @@ -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 diff --git a/tests/9_nip_delete_test.sh b/tests/9_nip_delete_test.sh index 8c40745..b353c53 100755 --- a/tests/9_nip_delete_test.sh +++ b/tests/9_nip_delete_test.sh @@ -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