From 656748ea286ff7eac6cbe1b241ad31212892ba61 Mon Sep 17 00:00:00 2001 From: bndw Date: Fri, 13 Feb 2026 19:12:28 -0800 Subject: 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 --- internal/handler/grpc/server.go | 11 ++ internal/handler/websocket/handler.go | 13 +++ internal/handler/websocket/nip11.go | 60 +++++++++++ internal/storage/deletions.go | 70 +++++++++++++ internal/storage/deletions_test.go | 184 ++++++++++++++++++++++++++++++++++ internal/storage/storage.go | 4 +- 6 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 internal/handler/websocket/nip11.go create mode 100644 internal/storage/deletions.go create mode 100644 internal/storage/deletions_test.go (limited to 'internal') 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 ( type EventStore interface { StoreEvent(context.Context, *storage.EventData) error QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error) + ProcessDeletion(context.Context, *pb.Event) error } type Server struct { @@ -75,6 +76,16 @@ func (s *Server) PublishEvent(ctx context.Context, req *pb.PublishEventRequest) return nil, fmt.Errorf("failed to store event: %w", err) } + if req.Event.Kind == 5 { + if err := s.store.ProcessDeletion(ctx, req.Event); err != nil { + return &pb.PublishEventResponse{ + Accepted: false, + Message: fmt.Sprintf("deletion processing failed: %v", err), + CanonicalJson: canonicalJSON, + }, nil + } + } + s.subs.MatchAndFan(req.Event) 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 ( type EventStore interface { StoreEvent(context.Context, *storage.EventData) error QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error) + ProcessDeletion(context.Context, *pb.Event) error } type Handler struct { @@ -32,6 +33,11 @@ func NewHandler(store EventStore, subs *subscription.Manager) *Handler { } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.Header.Get("Accept") == "application/nostr+json" { + h.ServeNIP11(w, r) + return + } + conn, err := websocket.Accept(w, r) if err != nil { log.Printf("WebSocket accept failed: %v", err) @@ -125,6 +131,13 @@ func (h *Handler) handleEvent(ctx context.Context, conn *websocket.Conn, raw []j return nil } + if pbEvent.Kind == 5 { + if err := h.store.ProcessDeletion(ctx, pbEvent); err != nil { + h.sendOK(ctx, conn, event.ID, false, fmt.Sprintf("deletion failed: %v", err)) + return nil + } + } + h.subs.MatchAndFan(pbEvent) 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 @@ +package websocket + +import ( + "encoding/json" + "net/http" +) + +type RelayInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Pubkey string `json:"pubkey,omitempty"` + Contact string `json:"contact,omitempty"` + SupportedNIPs []int `json:"supported_nips"` + Software string `json:"software"` + Version string `json:"version"` + Limitation *Limits `json:"limitation,omitempty"` +} + +type Limits struct { + MaxMessageLength int `json:"max_message_length,omitempty"` + MaxSubscriptions int `json:"max_subscriptions,omitempty"` + MaxFilters int `json:"max_filters,omitempty"` + MaxLimit int `json:"max_limit,omitempty"` + MaxSubidLength int `json:"max_subid_length,omitempty"` + MaxEventTags int `json:"max_event_tags,omitempty"` + MaxContentLength int `json:"max_content_length,omitempty"` + MinPowDifficulty int `json:"min_pow_difficulty,omitempty"` + AuthRequired bool `json:"auth_required"` + PaymentRequired bool `json:"payment_required"` + RestrictedWrites bool `json:"restricted_writes"` +} + +func (h *Handler) ServeNIP11(w http.ResponseWriter, r *http.Request) { + info := RelayInfo{ + Name: "nostr-grpc relay", + Description: "High-performance Nostr relay with gRPC, Connect, and WebSocket support", + SupportedNIPs: []int{1, 9, 11}, + Software: "northwest.io/nostr-grpc", + Version: "0.1.0", + Limitation: &Limits{ + MaxMessageLength: 65536, + MaxSubscriptions: 20, + MaxFilters: 10, + MaxLimit: 5000, + MaxSubidLength: 64, + MaxEventTags: 2000, + MaxContentLength: 65536, + AuthRequired: false, + PaymentRequired: false, + RestrictedWrites: false, + }, + } + + w.Header().Set("Content-Type", "application/nostr+json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept") + w.Header().Set("Access-Control-Allow-Methods", "GET") + + json.NewEncoder(w).Encode(info) +} 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 @@ +package storage + +import ( + "context" + "fmt" + + pb "northwest.io/nostr-grpc/api/nostr/v1" +) + +const KindDeletion = 5 + +func (s *Storage) ProcessDeletion(ctx context.Context, deletionEvent *pb.Event) error { + if deletionEvent.Kind != KindDeletion { + return fmt.Errorf("not a deletion event") + } + + 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() + + for _, eventID := range eventIDsToDelete { + var eventPubkey string + err := tx.QueryRowContext(ctx, + "SELECT pubkey FROM events WHERE id = ? AND deleted = 0", + eventID, + ).Scan(&eventPubkey) + + if err != nil { + continue + } + + if eventPubkey != deletionEvent.Pubkey { + continue + } + + _, err = tx.ExecContext(ctx, + "UPDATE events SET deleted = 1 WHERE id = ?", + eventID, + ) + if err != nil { + return fmt.Errorf("failed to mark event as deleted: %w", err) + } + + _, err = tx.ExecContext(ctx, + "INSERT OR IGNORE INTO deletions (deleted_event_id, deletion_event_id, pubkey, created_at) VALUES (?, ?, ?, ?)", + eventID, + deletionEvent.Id, + deletionEvent.Pubkey, + deletionEvent.CreatedAt, + ) + if err != nil { + return fmt.Errorf("failed to record deletion: %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..47e6547 --- /dev/null +++ b/internal/storage/deletions_test.go @@ -0,0 +1,184 @@ +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() + + 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) + } + + 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) + } + + 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) + } + + _, 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() + + 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) + } + + 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) + } + + 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() + + 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) + } + } + + 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) + } + + _, 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") + } + + _, err = store.GetEvent(ctx, "event3") + if err != nil { + t.Error("event3 should still exist") + } +} 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 { -- Deletion events (NIP-09) CREATE TABLE IF NOT EXISTS deletions ( - event_id TEXT PRIMARY KEY, -- ID of deletion event - deleted_event_id TEXT NOT NULL, -- ID of event being deleted + deleted_event_id TEXT PRIMARY KEY, -- ID of event being deleted + deletion_event_id TEXT NOT NULL, -- ID of deletion event pubkey TEXT NOT NULL, -- Who requested deletion created_at INTEGER NOT NULL, FOREIGN KEY (deleted_event_id) REFERENCES events(id) -- cgit v1.2.3