summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-13 19:12:28 -0800
committerbndw <ben@bdw.to>2026-02-13 19:12:28 -0800
commit656748ea286ff7eac6cbe1b241ad31212892ba61 (patch)
treee9685b4a585809463bdf51a4d1ecb7f7c5efaf70
parent83876eae868bd1e4fb6b9a823a6e8173919f290d (diff)
feat: implement NIP-09 (deletions) and NIP-11 (relay info)
NIP-11 (Relay Information Document): - Serves relay metadata at GET / with Accept: application/nostr+json - Returns name, description, supported NIPs, limitations - CORS headers for browser compatibility NIP-09 (Event Deletion): - Kind 5 events delete events referenced in 'e' tags - Only authors can delete their own events - Soft delete (marks deleted=1) - Records deletion in deletions table - Works across all protocols (gRPC, Connect, WebSocket) Fixed deletions schema: - deleted_event_id as PRIMARY KEY (not deletion_event_id) - Allows one deletion event to delete multiple events 3 new tests, 44 total tests passing Supported NIPs now: 1, 9, 11
-rw-r--r--README.md2
-rw-r--r--internal/handler/grpc/server.go11
-rw-r--r--internal/handler/websocket/handler.go13
-rw-r--r--internal/handler/websocket/nip11.go60
-rw-r--r--internal/storage/deletions.go70
-rw-r--r--internal/storage/deletions_test.go184
-rw-r--r--internal/storage/storage.go4
7 files changed, 342 insertions, 2 deletions
diff --git a/README.md b/README.md
index 35e697c..d2fbd41 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,8 @@ 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 (authors can delete their own events)
104- ✅ **NIP-11** - Relay info document (GET with `Accept: application/nostr+json`)
103 105
104**Compatible with:** 106**Compatible with:**
105- Any gRPC client (Go, Python, JS, etc.) 107- Any gRPC client (Go, Python, JS, etc.)
diff --git a/internal/handler/grpc/server.go b/internal/handler/grpc/server.go
index b65b527..b1ffd96 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 {
@@ -75,6 +76,16 @@ func (s *Server) PublishEvent(ctx context.Context, req *pb.PublishEventRequest)
75 return nil, fmt.Errorf("failed to store event: %w", err) 76 return nil, fmt.Errorf("failed to store event: %w", err)
76 } 77 }
77 78
79 if req.Event.Kind == 5 {
80 if err := s.store.ProcessDeletion(ctx, req.Event); err != nil {
81 return &pb.PublishEventResponse{
82 Accepted: false,
83 Message: fmt.Sprintf("deletion processing failed: %v", err),
84 CanonicalJson: canonicalJSON,
85 }, nil
86 }
87 }
88
78 s.subs.MatchAndFan(req.Event) 89 s.subs.MatchAndFan(req.Event)
79 90
80 return &pb.PublishEventResponse{ 91 return &pb.PublishEventResponse{
diff --git a/internal/handler/websocket/handler.go b/internal/handler/websocket/handler.go
index cef83dd..4a7db0d 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 {
@@ -32,6 +33,11 @@ func NewHandler(store EventStore, subs *subscription.Manager) *Handler {
32} 33}
33 34
34func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36 if r.Method == "GET" && r.Header.Get("Accept") == "application/nostr+json" {
37 h.ServeNIP11(w, r)
38 return
39 }
40
35 conn, err := websocket.Accept(w, r) 41 conn, err := websocket.Accept(w, r)
36 if err != nil { 42 if err != nil {
37 log.Printf("WebSocket accept failed: %v", err) 43 log.Printf("WebSocket accept failed: %v", err)
@@ -125,6 +131,13 @@ func (h *Handler) handleEvent(ctx context.Context, conn *websocket.Conn, raw []j
125 return nil 131 return nil
126 } 132 }
127 133
134 if pbEvent.Kind == 5 {
135 if err := h.store.ProcessDeletion(ctx, pbEvent); err != nil {
136 h.sendOK(ctx, conn, event.ID, false, fmt.Sprintf("deletion failed: %v", err))
137 return nil
138 }
139 }
140
128 h.subs.MatchAndFan(pbEvent) 141 h.subs.MatchAndFan(pbEvent)
129 142
130 h.sendOK(ctx, conn, event.ID, true, "") 143 h.sendOK(ctx, conn, event.ID, true, "")
diff --git a/internal/handler/websocket/nip11.go b/internal/handler/websocket/nip11.go
new file mode 100644
index 0000000..a5bb9ca
--- /dev/null
+++ b/internal/handler/websocket/nip11.go
@@ -0,0 +1,60 @@
1package websocket
2
3import (
4 "encoding/json"
5 "net/http"
6)
7
8type RelayInfo struct {
9 Name string `json:"name"`
10 Description string `json:"description"`
11 Pubkey string `json:"pubkey,omitempty"`
12 Contact string `json:"contact,omitempty"`
13 SupportedNIPs []int `json:"supported_nips"`
14 Software string `json:"software"`
15 Version string `json:"version"`
16 Limitation *Limits `json:"limitation,omitempty"`
17}
18
19type Limits struct {
20 MaxMessageLength int `json:"max_message_length,omitempty"`
21 MaxSubscriptions int `json:"max_subscriptions,omitempty"`
22 MaxFilters int `json:"max_filters,omitempty"`
23 MaxLimit int `json:"max_limit,omitempty"`
24 MaxSubidLength int `json:"max_subid_length,omitempty"`
25 MaxEventTags int `json:"max_event_tags,omitempty"`
26 MaxContentLength int `json:"max_content_length,omitempty"`
27 MinPowDifficulty int `json:"min_pow_difficulty,omitempty"`
28 AuthRequired bool `json:"auth_required"`
29 PaymentRequired bool `json:"payment_required"`
30 RestrictedWrites bool `json:"restricted_writes"`
31}
32
33func (h *Handler) ServeNIP11(w http.ResponseWriter, r *http.Request) {
34 info := RelayInfo{
35 Name: "nostr-grpc relay",
36 Description: "High-performance Nostr relay with gRPC, Connect, and WebSocket support",
37 SupportedNIPs: []int{1, 9, 11},
38 Software: "northwest.io/nostr-grpc",
39 Version: "0.1.0",
40 Limitation: &Limits{
41 MaxMessageLength: 65536,
42 MaxSubscriptions: 20,
43 MaxFilters: 10,
44 MaxLimit: 5000,
45 MaxSubidLength: 64,
46 MaxEventTags: 2000,
47 MaxContentLength: 65536,
48 AuthRequired: false,
49 PaymentRequired: false,
50 RestrictedWrites: false,
51 },
52 }
53
54 w.Header().Set("Content-Type", "application/nostr+json")
55 w.Header().Set("Access-Control-Allow-Origin", "*")
56 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
57 w.Header().Set("Access-Control-Allow-Methods", "GET")
58
59 json.NewEncoder(w).Encode(info)
60}
diff --git a/internal/storage/deletions.go b/internal/storage/deletions.go
new file mode 100644
index 0000000..1a07e3c
--- /dev/null
+++ b/internal/storage/deletions.go
@@ -0,0 +1,70 @@
1package storage
2
3import (
4 "context"
5 "fmt"
6
7 pb "northwest.io/nostr-grpc/api/nostr/v1"
8)
9
10const KindDeletion = 5
11
12func (s *Storage) ProcessDeletion(ctx context.Context, deletionEvent *pb.Event) error {
13 if deletionEvent.Kind != KindDeletion {
14 return fmt.Errorf("not a deletion event")
15 }
16
17 var eventIDsToDelete []string
18 for _, tag := range deletionEvent.Tags {
19 if len(tag.Values) >= 2 && tag.Values[0] == "e" {
20 eventIDsToDelete = append(eventIDsToDelete, tag.Values[1])
21 }
22 }
23
24 if len(eventIDsToDelete) == 0 {
25 return nil
26 }
27
28 tx, err := s.db.BeginTx(ctx, nil)
29 if err != nil {
30 return fmt.Errorf("failed to begin transaction: %w", err)
31 }
32 defer tx.Rollback()
33
34 for _, eventID := range eventIDsToDelete {
35 var eventPubkey string
36 err := tx.QueryRowContext(ctx,
37 "SELECT pubkey FROM events WHERE id = ? AND deleted = 0",
38 eventID,
39 ).Scan(&eventPubkey)
40
41 if err != nil {
42 continue
43 }
44
45 if eventPubkey != deletionEvent.Pubkey {
46 continue
47 }
48
49 _, err = tx.ExecContext(ctx,
50 "UPDATE events SET deleted = 1 WHERE id = ?",
51 eventID,
52 )
53 if err != nil {
54 return fmt.Errorf("failed to mark event as deleted: %w", err)
55 }
56
57 _, err = tx.ExecContext(ctx,
58 "INSERT OR IGNORE INTO deletions (deleted_event_id, deletion_event_id, pubkey, created_at) VALUES (?, ?, ?, ?)",
59 eventID,
60 deletionEvent.Id,
61 deletionEvent.Pubkey,
62 deletionEvent.CreatedAt,
63 )
64 if err != nil {
65 return fmt.Errorf("failed to record deletion: %w", err)
66 }
67 }
68
69 return tx.Commit()
70}
diff --git a/internal/storage/deletions_test.go b/internal/storage/deletions_test.go
new file mode 100644
index 0000000..47e6547
--- /dev/null
+++ b/internal/storage/deletions_test.go
@@ -0,0 +1,184 @@
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 event := &pb.Event{
22 Id: "event123",
23 Pubkey: "alice",
24 CreatedAt: time.Now().Unix(),
25 Kind: 1,
26 Tags: []*pb.Tag{},
27 Content: "to be deleted",
28 Sig: "sig1",
29 }
30
31 err = store.StoreEvent(ctx, &EventData{
32 Event: event,
33 CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"to be deleted"]`),
34 })
35 if err != nil {
36 t.Fatalf("failed to store event: %v", err)
37 }
38
39 retrieved, err := store.GetEvent(ctx, "event123")
40 if err != nil {
41 t.Fatalf("event should exist before deletion: %v", err)
42 }
43 if retrieved.Id != "event123" {
44 t.Errorf("expected event123, got %s", retrieved.Id)
45 }
46
47 deletionEvent := &pb.Event{
48 Id: "deletion123",
49 Pubkey: "alice",
50 CreatedAt: time.Now().Unix(),
51 Kind: KindDeletion,
52 Tags: []*pb.Tag{
53 {Values: []string{"e", "event123"}},
54 },
55 Content: "deleting my event",
56 Sig: "sig2",
57 }
58
59 err = store.ProcessDeletion(ctx, deletionEvent)
60 if err != nil {
61 t.Fatalf("failed to process deletion: %v", err)
62 }
63
64 _, err = store.GetEvent(ctx, "event123")
65 if err != ErrEventNotFound {
66 t.Errorf("event should be deleted, got error: %v", err)
67 }
68}
69
70func TestProcessDeletionWrongAuthor(t *testing.T) {
71 store, err := New(":memory:")
72 if err != nil {
73 t.Fatalf("failed to create storage: %v", err)
74 }
75 defer store.Close()
76
77 ctx := context.Background()
78
79 event := &pb.Event{
80 Id: "event456",
81 Pubkey: "alice",
82 CreatedAt: time.Now().Unix(),
83 Kind: 1,
84 Tags: []*pb.Tag{},
85 Content: "alice's event",
86 Sig: "sig1",
87 }
88
89 err = store.StoreEvent(ctx, &EventData{
90 Event: event,
91 CanonicalJSON: []byte(`[0,"alice",1234567890,1,[],"alice's event"]`),
92 })
93 if err != nil {
94 t.Fatalf("failed to store event: %v", err)
95 }
96
97 deletionEvent := &pb.Event{
98 Id: "deletion456",
99 Pubkey: "bob",
100 CreatedAt: time.Now().Unix(),
101 Kind: KindDeletion,
102 Tags: []*pb.Tag{
103 {Values: []string{"e", "event456"}},
104 },
105 Content: "trying to delete alice's event",
106 Sig: "sig2",
107 }
108
109 err = store.ProcessDeletion(ctx, deletionEvent)
110 if err != nil {
111 t.Fatalf("process deletion should succeed but not delete: %v", err)
112 }
113
114 retrieved, err := store.GetEvent(ctx, "event456")
115 if err != nil {
116 t.Fatalf("event should still exist: %v", err)
117 }
118 if retrieved.Id != "event456" {
119 t.Errorf("expected event456, got %s", retrieved.Id)
120 }
121}
122
123func TestProcessDeletionMultipleEvents(t *testing.T) {
124 store, err := New(":memory:")
125 if err != nil {
126 t.Fatalf("failed to create storage: %v", err)
127 }
128 defer store.Close()
129
130 ctx := context.Background()
131
132 for i := 1; i <= 3; i++ {
133 event := &pb.Event{
134 Id: fmt.Sprintf("event%d", i),
135 Pubkey: "alice",
136 CreatedAt: time.Now().Unix(),
137 Kind: 1,
138 Tags: []*pb.Tag{},
139 Content: fmt.Sprintf("event %d", i),
140 Sig: fmt.Sprintf("sig%d", i),
141 }
142
143 err = store.StoreEvent(ctx, &EventData{
144 Event: event,
145 CanonicalJSON: []byte(fmt.Sprintf(`[0,"alice",1234567890,1,[],"event %d"]`, i)),
146 })
147 if err != nil {
148 t.Fatalf("failed to store event: %v", err)
149 }
150 }
151
152 deletionEvent := &pb.Event{
153 Id: "deletion789",
154 Pubkey: "alice",
155 CreatedAt: time.Now().Unix(),
156 Kind: KindDeletion,
157 Tags: []*pb.Tag{
158 {Values: []string{"e", "event1"}},
159 {Values: []string{"e", "event2"}},
160 },
161 Content: "deleting multiple events",
162 Sig: "sig_del",
163 }
164
165 err = store.ProcessDeletion(ctx, deletionEvent)
166 if err != nil {
167 t.Fatalf("failed to process deletion: %v", err)
168 }
169
170 _, err = store.GetEvent(ctx, "event1")
171 if err != ErrEventNotFound {
172 t.Error("event1 should be deleted")
173 }
174
175 _, err = store.GetEvent(ctx, "event2")
176 if err != ErrEventNotFound {
177 t.Error("event2 should be deleted")
178 }
179
180 _, err = store.GetEvent(ctx, "event3")
181 if err != nil {
182 t.Error("event3 should still exist")
183 }
184}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 64fc4c6..806acac 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -101,8 +101,8 @@ func (s *Storage) initSchema(ctx context.Context) error {
101 101
102 -- Deletion events (NIP-09) 102 -- Deletion events (NIP-09)
103 CREATE TABLE IF NOT EXISTS deletions ( 103 CREATE TABLE IF NOT EXISTS deletions (
104 event_id TEXT PRIMARY KEY, -- ID of deletion event 104 deleted_event_id TEXT PRIMARY KEY, -- ID of event being deleted
105 deleted_event_id TEXT NOT NULL, -- ID of event being deleted 105 deletion_event_id TEXT NOT NULL, -- ID of deletion event
106 pubkey TEXT NOT NULL, -- Who requested deletion 106 pubkey TEXT NOT NULL, -- Who requested deletion
107 created_at INTEGER NOT NULL, 107 created_at INTEGER NOT NULL,
108 FOREIGN KEY (deleted_event_id) REFERENCES events(id) 108 FOREIGN KEY (deleted_event_id) REFERENCES events(id)