summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--internal/handler/grpc/server.go17
-rw-r--r--internal/handler/websocket/handler.go11
-rw-r--r--internal/storage/deletions.go67
-rw-r--r--internal/storage/deletions_test.go224
5 files changed, 320 insertions, 0 deletions
diff --git a/README.md b/README.md
index 61fa0d4..18bef37 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ See [proto/nostr/v1/nostr.proto](proto/nostr/v1/nostr.proto) for the full API.
100 - **WebSocket** (NIP-01 - standard Nostr protocol) 100 - **WebSocket** (NIP-01 - standard Nostr protocol)
101- ✅ Subscribe/streaming (real-time event delivery) 101- ✅ Subscribe/streaming (real-time event delivery)
102- ✅ Subscription management (filter matching, fan-out) 102- ✅ Subscription management (filter matching, fan-out)
103- ✅ **NIP-09** - Event deletion (hard delete, authors can delete their own events)
103- ✅ **NIP-11** - Relay info document (GET with `Accept: application/nostr+json`) 104- ✅ **NIP-11** - Relay info document (GET with `Accept: application/nostr+json`)
104 105
105**Compatible with:** 106**Compatible with:**
diff --git a/internal/handler/grpc/server.go b/internal/handler/grpc/server.go
index b65b527..4d6e700 100644
--- a/internal/handler/grpc/server.go
+++ b/internal/handler/grpc/server.go
@@ -13,6 +13,7 @@ import (
13type EventStore interface { 13type EventStore interface {
14 StoreEvent(context.Context, *storage.EventData) error 14 StoreEvent(context.Context, *storage.EventData) error
15 QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error) 15 QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error)
16 ProcessDeletion(context.Context, *pb.Event) error
16} 17}
17 18
18type Server struct { 19type Server struct {
@@ -58,6 +59,22 @@ func (s *Server) PublishEvent(ctx context.Context, req *pb.PublishEventRequest)
58 59
59 canonicalJSON := nostrEvent.Serialize() 60 canonicalJSON := nostrEvent.Serialize()
60 61
62 // Handle deletion events (kind 5) - process but don't store
63 if req.Event.Kind == 5 {
64 if err := s.store.ProcessDeletion(ctx, req.Event); err != nil {
65 return &pb.PublishEventResponse{
66 Accepted: false,
67 Message: fmt.Sprintf("deletion failed: %v", err),
68 CanonicalJson: canonicalJSON,
69 }, nil
70 }
71 return &pb.PublishEventResponse{
72 Accepted: true,
73 Message: "deleted",
74 CanonicalJson: canonicalJSON,
75 }, nil
76 }
77
61 eventData := &storage.EventData{ 78 eventData := &storage.EventData{
62 Event: req.Event, 79 Event: req.Event,
63 CanonicalJSON: canonicalJSON, 80 CanonicalJSON: canonicalJSON,
diff --git a/internal/handler/websocket/handler.go b/internal/handler/websocket/handler.go
index 38d4fa6..224a2f8 100644
--- a/internal/handler/websocket/handler.go
+++ b/internal/handler/websocket/handler.go
@@ -17,6 +17,7 @@ import (
17type EventStore interface { 17type EventStore interface {
18 StoreEvent(context.Context, *storage.EventData) error 18 StoreEvent(context.Context, *storage.EventData) error
19 QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error) 19 QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error)
20 ProcessDeletion(context.Context, *pb.Event) error
20} 21}
21 22
22type Handler struct { 23type Handler struct {
@@ -115,6 +116,16 @@ func (h *Handler) handleEvent(ctx context.Context, conn *websocket.Conn, raw []j
115 pbEvent := NostrToPB(&event) 116 pbEvent := NostrToPB(&event)
116 canonicalJSON := event.Serialize() 117 canonicalJSON := event.Serialize()
117 118
119 // Handle deletion events (kind 5) - process but don't store
120 if pbEvent.Kind == 5 {
121 if err := h.store.ProcessDeletion(ctx, pbEvent); err != nil {
122 h.sendOK(ctx, conn, event.ID, false, fmt.Sprintf("deletion failed: %v", err))
123 return nil
124 }
125 h.sendOK(ctx, conn, event.ID, true, "deleted")
126 return nil
127 }
128
118 eventData := &storage.EventData{ 129 eventData := &storage.EventData{
119 Event: pbEvent, 130 Event: pbEvent,
120 CanonicalJSON: canonicalJSON, 131 CanonicalJSON: canonicalJSON,
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 @@
1package storage
2
3import (
4 "context"
5 "fmt"
6
7 pb "northwest.io/nostr-grpc/api/nostr/v1"
8)
9
10const KindDeletion = 5
11
12// ProcessDeletion handles kind 5 deletion events by hard deleting the referenced events.
13// Only events authored by the same pubkey as the deletion event can be deleted.
14func (s *Storage) ProcessDeletion(ctx context.Context, deletionEvent *pb.Event) error {
15 if deletionEvent.Kind != KindDeletion {
16 return fmt.Errorf("not a deletion event")
17 }
18
19 // Extract event IDs to delete from "e" tags
20 var eventIDsToDelete []string
21 for _, tag := range deletionEvent.Tags {
22 if len(tag.Values) >= 2 && tag.Values[0] == "e" {
23 eventIDsToDelete = append(eventIDsToDelete, tag.Values[1])
24 }
25 }
26
27 if len(eventIDsToDelete) == 0 {
28 return nil
29 }
30
31 tx, err := s.db.BeginTx(ctx, nil)
32 if err != nil {
33 return fmt.Errorf("failed to begin transaction: %w", err)
34 }
35 defer tx.Rollback()
36
37 // Hard delete each referenced event (only if authored by same pubkey)
38 for _, eventID := range eventIDsToDelete {
39 // Verify the event exists and was authored by the same pubkey
40 var eventPubkey string
41 err := tx.QueryRowContext(ctx,
42 "SELECT pubkey FROM events WHERE id = ?",
43 eventID,
44 ).Scan(&eventPubkey)
45
46 if err != nil {
47 // Event doesn't exist, skip
48 continue
49 }
50
51 // Only delete if pubkeys match
52 if eventPubkey != deletionEvent.Pubkey {
53 continue
54 }
55
56 // Hard delete the event
57 _, err = tx.ExecContext(ctx,
58 "DELETE FROM events WHERE id = ?",
59 eventID,
60 )
61 if err != nil {
62 return fmt.Errorf("failed to delete event: %w", err)
63 }
64 }
65
66 return tx.Commit()
67}
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 @@
1package storage
2
3import (
4 "context"
5 "fmt"
6 "testing"
7 "time"
8
9 pb "northwest.io/nostr-grpc/api/nostr/v1"
10)
11
12func TestProcessDeletion(t *testing.T) {
13 store, err := New(":memory:")
14 if err != nil {
15 t.Fatalf("failed to create storage: %v", err)
16 }
17 defer store.Close()
18
19 ctx := context.Background()
20
21 // Store an event
22 event := &pb.Event{
23 Id: "event123",
24 Pubkey: "alice",
25 CreatedAt: time.Now().Unix(),
26 Kind: 1,
27 Tags: []*pb.Tag{},
28 Content: "to be deleted",
29 Sig: "sig1",
30 }
31
32 err = store.StoreEvent(ctx, &EventData{
33 Event: event,
34 CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"to be deleted"]`),
35 })
36 if err != nil {
37 t.Fatalf("failed to store event: %v", err)
38 }
39
40 // Verify event exists
41 retrieved, err := store.GetEvent(ctx, "event123")
42 if err != nil {
43 t.Fatalf("event should exist before deletion: %v", err)
44 }
45 if retrieved.Id != "event123" {
46 t.Errorf("expected event123, got %s", retrieved.Id)
47 }
48
49 // Process deletion
50 deletionEvent := &pb.Event{
51 Id: "deletion123",
52 Pubkey: "alice",
53 CreatedAt: time.Now().Unix(),
54 Kind: KindDeletion,
55 Tags: []*pb.Tag{
56 {Values: []string{"e", "event123"}},
57 },
58 Content: "deleting my event",
59 Sig: "sig2",
60 }
61
62 err = store.ProcessDeletion(ctx, deletionEvent)
63 if err != nil {
64 t.Fatalf("failed to process deletion: %v", err)
65 }
66
67 // Verify event was hard deleted
68 _, err = store.GetEvent(ctx, "event123")
69 if err != ErrEventNotFound {
70 t.Errorf("event should be deleted, got error: %v", err)
71 }
72}
73
74func TestProcessDeletionWrongAuthor(t *testing.T) {
75 store, err := New(":memory:")
76 if err != nil {
77 t.Fatalf("failed to create storage: %v", err)
78 }
79 defer store.Close()
80
81 ctx := context.Background()
82
83 // Store an event from alice
84 event := &pb.Event{
85 Id: "event456",
86 Pubkey: "alice",
87 CreatedAt: time.Now().Unix(),
88 Kind: 1,
89 Tags: []*pb.Tag{},
90 Content: "alice's event",
91 Sig: "sig1",
92 }
93
94 err = store.StoreEvent(ctx, &EventData{
95 Event: event,
96 CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"alice's event"]`),
97 })
98 if err != nil {
99 t.Fatalf("failed to store event: %v", err)
100 }
101
102 // Try to delete with bob's pubkey
103 deletionEvent := &pb.Event{
104 Id: "deletion456",
105 Pubkey: "bob",
106 CreatedAt: time.Now().Unix(),
107 Kind: KindDeletion,
108 Tags: []*pb.Tag{
109 {Values: []string{"e", "event456"}},
110 },
111 Content: "trying to delete alice's event",
112 Sig: "sig2",
113 }
114
115 err = store.ProcessDeletion(ctx, deletionEvent)
116 if err != nil {
117 t.Fatalf("process deletion should succeed but not delete: %v", err)
118 }
119
120 // Verify event still exists (bob can't delete alice's events)
121 retrieved, err := store.GetEvent(ctx, "event456")
122 if err != nil {
123 t.Fatalf("event should still exist: %v", err)
124 }
125 if retrieved.Id != "event456" {
126 t.Errorf("expected event456, got %s", retrieved.Id)
127 }
128}
129
130func TestProcessDeletionMultipleEvents(t *testing.T) {
131 store, err := New(":memory:")
132 if err != nil {
133 t.Fatalf("failed to create storage: %v", err)
134 }
135 defer store.Close()
136
137 ctx := context.Background()
138
139 // Store 3 events from alice
140 for i := 1; i <= 3; i++ {
141 event := &pb.Event{
142 Id: fmt.Sprintf("event%d", i),
143 Pubkey: "alice",
144 CreatedAt: time.Now().Unix(),
145 Kind: 1,
146 Tags: []*pb.Tag{},
147 Content: fmt.Sprintf("event %d", i),
148 Sig: fmt.Sprintf("sig%d", i),
149 }
150
151 err = store.StoreEvent(ctx, &EventData{
152 Event: event,
153 CanonicalJSON: []byte(fmt.Sprintf(`[0,"alice",1234567890,1,[],"event %d"]`, i)),
154 })
155 if err != nil {
156 t.Fatalf("failed to store event: %v", err)
157 }
158 }
159
160 // Delete event1 and event2
161 deletionEvent := &pb.Event{
162 Id: "deletion789",
163 Pubkey: "alice",
164 CreatedAt: time.Now().Unix(),
165 Kind: KindDeletion,
166 Tags: []*pb.Tag{
167 {Values: []string{"e", "event1"}},
168 {Values: []string{"e", "event2"}},
169 },
170 Content: "deleting multiple events",
171 Sig: "sig_del",
172 }
173
174 err = store.ProcessDeletion(ctx, deletionEvent)
175 if err != nil {
176 t.Fatalf("failed to process deletion: %v", err)
177 }
178
179 // Verify event1 and event2 are deleted
180 _, err = store.GetEvent(ctx, "event1")
181 if err != ErrEventNotFound {
182 t.Error("event1 should be deleted")
183 }
184
185 _, err = store.GetEvent(ctx, "event2")
186 if err != ErrEventNotFound {
187 t.Error("event2 should be deleted")
188 }
189
190 // Verify event3 still exists
191 _, err = store.GetEvent(ctx, "event3")
192 if err != nil {
193 t.Error("event3 should still exist")
194 }
195}
196
197func TestProcessDeletionNonExistentEvent(t *testing.T) {
198 store, err := New(":memory:")
199 if err != nil {
200 t.Fatalf("failed to create storage: %v", err)
201 }
202 defer store.Close()
203
204 ctx := context.Background()
205
206 // Try to delete an event that doesn't exist
207 deletionEvent := &pb.Event{
208 Id: "deletion999",
209 Pubkey: "alice",
210 CreatedAt: time.Now().Unix(),
211 Kind: KindDeletion,
212 Tags: []*pb.Tag{
213 {Values: []string{"e", "nonexistent"}},
214 },
215 Content: "deleting nonexistent event",
216 Sig: "sig_del",
217 }
218
219 // Should succeed without error (no-op)
220 err = store.ProcessDeletion(ctx, deletionEvent)
221 if err != nil {
222 t.Fatalf("process deletion should succeed: %v", err)
223 }
224}