From dfa19ff0776be0850ad7b86ca579601431349593 Mon Sep 17 00:00:00 2001 From: bndw Date: Fri, 13 Feb 2026 20:38:59 -0800 Subject: feat: implement NIP-09 with hard delete Implement event deletion (NIP-09) using hard delete approach: - Kind 5 events trigger deletion but are not stored themselves - ProcessDeletion hard deletes referenced events (DELETE FROM events) - Only authors can delete their own events (pubkey verification) - Support multiple event IDs in single deletion request - No deletions table needed (simpler schema) - Added 4 deletion tests covering various scenarios - All 45 tests passing --- internal/storage/deletions.go | 67 +++++++++++ internal/storage/deletions_test.go | 224 +++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 internal/storage/deletions.go create mode 100644 internal/storage/deletions_test.go (limited to 'internal/storage') diff --git a/internal/storage/deletions.go b/internal/storage/deletions.go new file mode 100644 index 0000000..c16b96b --- /dev/null +++ b/internal/storage/deletions.go @@ -0,0 +1,67 @@ +package storage + +import ( + "context" + "fmt" + + pb "northwest.io/nostr-grpc/api/nostr/v1" +) + +const KindDeletion = 5 + +// ProcessDeletion handles kind 5 deletion events by hard deleting the referenced events. +// Only events authored by the same pubkey as the deletion event can be deleted. +func (s *Storage) ProcessDeletion(ctx context.Context, deletionEvent *pb.Event) error { + if deletionEvent.Kind != KindDeletion { + return fmt.Errorf("not a deletion event") + } + + // Extract event IDs to delete from "e" tags + var eventIDsToDelete []string + for _, tag := range deletionEvent.Tags { + if len(tag.Values) >= 2 && tag.Values[0] == "e" { + eventIDsToDelete = append(eventIDsToDelete, tag.Values[1]) + } + } + + if len(eventIDsToDelete) == 0 { + return nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Hard delete each referenced event (only if authored by same pubkey) + for _, eventID := range eventIDsToDelete { + // Verify the event exists and was authored by the same pubkey + var eventPubkey string + err := tx.QueryRowContext(ctx, + "SELECT pubkey FROM events WHERE id = ?", + eventID, + ).Scan(&eventPubkey) + + if err != nil { + // Event doesn't exist, skip + continue + } + + // Only delete if pubkeys match + if eventPubkey != deletionEvent.Pubkey { + continue + } + + // Hard delete the event + _, err = tx.ExecContext(ctx, + "DELETE FROM events WHERE id = ?", + eventID, + ) + if err != nil { + return fmt.Errorf("failed to delete event: %w", err) + } + } + + return tx.Commit() +} diff --git a/internal/storage/deletions_test.go b/internal/storage/deletions_test.go new file mode 100644 index 0000000..71fa477 --- /dev/null +++ b/internal/storage/deletions_test.go @@ -0,0 +1,224 @@ +package storage + +import ( + "context" + "fmt" + "testing" + "time" + + pb "northwest.io/nostr-grpc/api/nostr/v1" +) + +func TestProcessDeletion(t *testing.T) { + store, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Store an event + event := &pb.Event{ + Id: "event123", + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: []*pb.Tag{}, + Content: "to be deleted", + Sig: "sig1", + } + + err = store.StoreEvent(ctx, &EventData{ + Event: event, + CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"to be deleted"]`), + }) + if err != nil { + t.Fatalf("failed to store event: %v", err) + } + + // Verify event exists + retrieved, err := store.GetEvent(ctx, "event123") + if err != nil { + t.Fatalf("event should exist before deletion: %v", err) + } + if retrieved.Id != "event123" { + t.Errorf("expected event123, got %s", retrieved.Id) + } + + // Process deletion + deletionEvent := &pb.Event{ + Id: "deletion123", + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: KindDeletion, + Tags: []*pb.Tag{ + {Values: []string{"e", "event123"}}, + }, + Content: "deleting my event", + Sig: "sig2", + } + + err = store.ProcessDeletion(ctx, deletionEvent) + if err != nil { + t.Fatalf("failed to process deletion: %v", err) + } + + // Verify event was hard deleted + _, err = store.GetEvent(ctx, "event123") + if err != ErrEventNotFound { + t.Errorf("event should be deleted, got error: %v", err) + } +} + +func TestProcessDeletionWrongAuthor(t *testing.T) { + store, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Store an event from alice + event := &pb.Event{ + Id: "event456", + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: []*pb.Tag{}, + Content: "alice's event", + Sig: "sig1", + } + + err = store.StoreEvent(ctx, &EventData{ + Event: event, + CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"alice's event"]`), + }) + if err != nil { + t.Fatalf("failed to store event: %v", err) + } + + // Try to delete with bob's pubkey + deletionEvent := &pb.Event{ + Id: "deletion456", + Pubkey: "bob", + CreatedAt: time.Now().Unix(), + Kind: KindDeletion, + Tags: []*pb.Tag{ + {Values: []string{"e", "event456"}}, + }, + Content: "trying to delete alice's event", + Sig: "sig2", + } + + err = store.ProcessDeletion(ctx, deletionEvent) + if err != nil { + t.Fatalf("process deletion should succeed but not delete: %v", err) + } + + // Verify event still exists (bob can't delete alice's events) + retrieved, err := store.GetEvent(ctx, "event456") + if err != nil { + t.Fatalf("event should still exist: %v", err) + } + if retrieved.Id != "event456" { + t.Errorf("expected event456, got %s", retrieved.Id) + } +} + +func TestProcessDeletionMultipleEvents(t *testing.T) { + store, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Store 3 events from alice + for i := 1; i <= 3; i++ { + event := &pb.Event{ + Id: fmt.Sprintf("event%d", i), + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: []*pb.Tag{}, + Content: fmt.Sprintf("event %d", i), + Sig: fmt.Sprintf("sig%d", i), + } + + err = store.StoreEvent(ctx, &EventData{ + Event: event, + CanonicalJSON: []byte(fmt.Sprintf(`[0,"alice",1234567890,1,[],"event %d"]`, i)), + }) + if err != nil { + t.Fatalf("failed to store event: %v", err) + } + } + + // Delete event1 and event2 + deletionEvent := &pb.Event{ + Id: "deletion789", + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: KindDeletion, + Tags: []*pb.Tag{ + {Values: []string{"e", "event1"}}, + {Values: []string{"e", "event2"}}, + }, + Content: "deleting multiple events", + Sig: "sig_del", + } + + err = store.ProcessDeletion(ctx, deletionEvent) + if err != nil { + t.Fatalf("failed to process deletion: %v", err) + } + + // Verify event1 and event2 are deleted + _, err = store.GetEvent(ctx, "event1") + if err != ErrEventNotFound { + t.Error("event1 should be deleted") + } + + _, err = store.GetEvent(ctx, "event2") + if err != ErrEventNotFound { + t.Error("event2 should be deleted") + } + + // Verify event3 still exists + _, err = store.GetEvent(ctx, "event3") + if err != nil { + t.Error("event3 should still exist") + } +} + +func TestProcessDeletionNonExistentEvent(t *testing.T) { + store, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Try to delete an event that doesn't exist + deletionEvent := &pb.Event{ + Id: "deletion999", + Pubkey: "alice", + CreatedAt: time.Now().Unix(), + Kind: KindDeletion, + Tags: []*pb.Tag{ + {Values: []string{"e", "nonexistent"}}, + }, + Content: "deleting nonexistent event", + Sig: "sig_del", + } + + // Should succeed without error (no-op) + err = store.ProcessDeletion(ctx, deletionEvent) + if err != nil { + t.Fatalf("process deletion should succeed: %v", err) + } +} -- cgit v1.2.3