From 62d31434ddbadff18580826576e1169f539e23f0 Mon Sep 17 00:00:00 2001 From: bndw Date: Fri, 13 Feb 2026 17:48:36 -0800 Subject: feat: add gRPC handler with event validation and publishing Handler implementation: - EventStore interface (consumer-side) - Server with PublishEvent, QueryEvents, CountEvents, PublishBatch - pb.Event <-> nostr.Event conversion helpers - Signature and ID validation using existing nostr package - Canonical JSON generation for storage 9 tests passing --- internal/handler/grpc/convert.go | 40 +++++ internal/handler/grpc/convert_test.go | 143 +++++++++++++++++ internal/handler/grpc/server.go | 128 ++++++++++++++++ internal/handler/grpc/server_test.go | 281 ++++++++++++++++++++++++++++++++++ 4 files changed, 592 insertions(+) create mode 100644 internal/handler/grpc/convert.go create mode 100644 internal/handler/grpc/convert_test.go create mode 100644 internal/handler/grpc/server.go create mode 100644 internal/handler/grpc/server_test.go (limited to 'internal/handler/grpc') diff --git a/internal/handler/grpc/convert.go b/internal/handler/grpc/convert.go new file mode 100644 index 0000000..19505cd --- /dev/null +++ b/internal/handler/grpc/convert.go @@ -0,0 +1,40 @@ +package grpc + +import ( + pb "northwest.io/nostr-grpc/api/nostr/v1" + "northwest.io/nostr-grpc/internal/nostr" +) + +func NostrToPB(n *nostr.Event) *pb.Event { + tags := make([]*pb.Tag, len(n.Tags)) + for i, tag := range n.Tags { + tags[i] = &pb.Tag{Values: tag} + } + + return &pb.Event{ + Id: n.ID, + Pubkey: n.PubKey, + CreatedAt: n.CreatedAt, + Kind: int32(n.Kind), + Tags: tags, + Content: n.Content, + Sig: n.Sig, + } +} + +func PBToNostr(e *pb.Event) *nostr.Event { + tags := make(nostr.Tags, len(e.Tags)) + for i, tag := range e.Tags { + tags[i] = tag.Values + } + + return &nostr.Event{ + ID: e.Id, + PubKey: e.Pubkey, + CreatedAt: e.CreatedAt, + Kind: int(e.Kind), + Tags: tags, + Content: e.Content, + Sig: e.Sig, + } +} diff --git a/internal/handler/grpc/convert_test.go b/internal/handler/grpc/convert_test.go new file mode 100644 index 0000000..6da2d89 --- /dev/null +++ b/internal/handler/grpc/convert_test.go @@ -0,0 +1,143 @@ +package grpc + +import ( + "testing" + + pb "northwest.io/nostr-grpc/api/nostr/v1" + "northwest.io/nostr-grpc/internal/nostr" +) + +func TestNostrToPB(t *testing.T) { + nostrEvent := &nostr.Event{ + ID: "abc123", + PubKey: "pubkey123", + CreatedAt: 1234567890, + Kind: 1, + Tags: nostr.Tags{{"e", "event1"}, {"p", "pubkey1"}}, + Content: "Hello, Nostr!", + Sig: "sig123", + } + + pbEvent := NostrToPB(nostrEvent) + + if pbEvent.Id != nostrEvent.ID { + t.Errorf("ID mismatch: expected %s, got %s", nostrEvent.ID, pbEvent.Id) + } + if pbEvent.Pubkey != nostrEvent.PubKey { + t.Errorf("Pubkey mismatch: expected %s, got %s", nostrEvent.PubKey, pbEvent.Pubkey) + } + if pbEvent.CreatedAt != nostrEvent.CreatedAt { + t.Errorf("CreatedAt mismatch: expected %d, got %d", nostrEvent.CreatedAt, pbEvent.CreatedAt) + } + if pbEvent.Kind != int32(nostrEvent.Kind) { + t.Errorf("Kind mismatch: expected %d, got %d", nostrEvent.Kind, pbEvent.Kind) + } + if pbEvent.Content != nostrEvent.Content { + t.Errorf("Content mismatch: expected %s, got %s", nostrEvent.Content, pbEvent.Content) + } + if pbEvent.Sig != nostrEvent.Sig { + t.Errorf("Sig mismatch: expected %s, got %s", nostrEvent.Sig, pbEvent.Sig) + } + + if len(pbEvent.Tags) != len(nostrEvent.Tags) { + t.Fatalf("Tags length mismatch: expected %d, got %d", len(nostrEvent.Tags), len(pbEvent.Tags)) + } + + for i, tag := range pbEvent.Tags { + if len(tag.Values) != len(nostrEvent.Tags[i]) { + t.Errorf("Tag[%d] values length mismatch", i) + continue + } + for j, val := range tag.Values { + if val != nostrEvent.Tags[i][j] { + t.Errorf("Tag[%d][%d] mismatch: expected %s, got %s", i, j, nostrEvent.Tags[i][j], val) + } + } + } +} + +func TestPBToNostr(t *testing.T) { + pbEvent := &pb.Event{ + Id: "abc123", + Pubkey: "pubkey123", + CreatedAt: 1234567890, + Kind: 1, + Tags: []*pb.Tag{{Values: []string{"e", "event1"}}, {Values: []string{"p", "pubkey1"}}}, + Content: "Hello, Nostr!", + Sig: "sig123", + } + + nostrEvent := PBToNostr(pbEvent) + + if nostrEvent.ID != pbEvent.Id { + t.Errorf("ID mismatch: expected %s, got %s", pbEvent.Id, nostrEvent.ID) + } + if nostrEvent.PubKey != pbEvent.Pubkey { + t.Errorf("Pubkey mismatch: expected %s, got %s", pbEvent.Pubkey, nostrEvent.PubKey) + } + if nostrEvent.CreatedAt != pbEvent.CreatedAt { + t.Errorf("CreatedAt mismatch: expected %d, got %d", pbEvent.CreatedAt, nostrEvent.CreatedAt) + } + if nostrEvent.Kind != int(pbEvent.Kind) { + t.Errorf("Kind mismatch: expected %d, got %d", pbEvent.Kind, nostrEvent.Kind) + } + if nostrEvent.Content != pbEvent.Content { + t.Errorf("Content mismatch: expected %s, got %s", pbEvent.Content, nostrEvent.Content) + } + if nostrEvent.Sig != pbEvent.Sig { + t.Errorf("Sig mismatch: expected %s, got %s", pbEvent.Sig, nostrEvent.Sig) + } + + if len(nostrEvent.Tags) != len(pbEvent.Tags) { + t.Fatalf("Tags length mismatch: expected %d, got %d", len(pbEvent.Tags), len(nostrEvent.Tags)) + } + + for i, tag := range nostrEvent.Tags { + if len(tag) != len(pbEvent.Tags[i].Values) { + t.Errorf("Tag[%d] values length mismatch", i) + continue + } + for j, val := range tag { + if val != pbEvent.Tags[i].Values[j] { + t.Errorf("Tag[%d][%d] mismatch: expected %s, got %s", i, j, pbEvent.Tags[i].Values[j], val) + } + } + } +} + +func TestRoundTrip(t *testing.T) { + original := &nostr.Event{ + ID: "roundtrip123", + PubKey: "pubkey_roundtrip", + CreatedAt: 9876543210, + Kind: 7, + Tags: nostr.Tags{{"e", "evt"}, {"p", "pk", "relay"}}, + Content: "Round trip test", + Sig: "signature", + } + + pb := NostrToPB(original) + backToNostr := PBToNostr(pb) + + if backToNostr.ID != original.ID { + t.Errorf("Round trip ID mismatch") + } + if backToNostr.PubKey != original.PubKey { + t.Errorf("Round trip PubKey mismatch") + } + if backToNostr.CreatedAt != original.CreatedAt { + t.Errorf("Round trip CreatedAt mismatch") + } + if backToNostr.Kind != original.Kind { + t.Errorf("Round trip Kind mismatch") + } + if backToNostr.Content != original.Content { + t.Errorf("Round trip Content mismatch") + } + if backToNostr.Sig != original.Sig { + t.Errorf("Round trip Sig mismatch") + } + if len(backToNostr.Tags) != len(original.Tags) { + t.Fatalf("Round trip Tags length mismatch") + } +} diff --git a/internal/handler/grpc/server.go b/internal/handler/grpc/server.go new file mode 100644 index 0000000..a3a3175 --- /dev/null +++ b/internal/handler/grpc/server.go @@ -0,0 +1,128 @@ +package grpc + +import ( + "context" + "fmt" + + pb "northwest.io/nostr-grpc/api/nostr/v1" + "northwest.io/nostr-grpc/internal/storage" +) + +type EventStore interface { + StoreEvent(context.Context, *storage.EventData) error + QueryEvents(context.Context, []*pb.Filter, *storage.QueryOptions) ([]*pb.Event, error) +} + +type Server struct { + pb.UnimplementedNostrRelayServer + store EventStore +} + +func NewServer(store EventStore) *Server { + return &Server{store: store} +} + +func (s *Server) PublishEvent(ctx context.Context, req *pb.PublishEventRequest) (*pb.PublishEventResponse, error) { + if req.Event == nil { + return &pb.PublishEventResponse{ + Accepted: false, + Message: "event is required", + }, nil + } + + nostrEvent := PBToNostr(req.Event) + + if !nostrEvent.CheckID() { + return &pb.PublishEventResponse{ + Accepted: false, + Message: "invalid event ID", + }, nil + } + + if !nostrEvent.Verify() { + return &pb.PublishEventResponse{ + Accepted: false, + Message: "invalid signature", + }, nil + } + + canonicalJSON := nostrEvent.Serialize() + + eventData := &storage.EventData{ + Event: req.Event, + CanonicalJSON: canonicalJSON, + } + + err := s.store.StoreEvent(ctx, eventData) + if err == storage.ErrEventExists { + return &pb.PublishEventResponse{ + Accepted: false, + Message: "duplicate: event already exists", + CanonicalJson: canonicalJSON, + }, nil + } + if err != nil { + return nil, fmt.Errorf("failed to store event: %w", err) + } + + return &pb.PublishEventResponse{ + Accepted: true, + Message: "success", + CanonicalJson: canonicalJSON, + }, nil +} + +func (s *Server) QueryEvents(ctx context.Context, req *pb.QueryRequest) (*pb.QueryResponse, error) { + opts := &storage.QueryOptions{ + IncludeCanonical: req.IncludeCanonicalJson, + Limit: req.PageSize, + } + + if opts.Limit == 0 { + opts.Limit = 100 + } + + events, err := s.store.QueryEvents(ctx, req.Filters, opts) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + + return &pb.QueryResponse{ + Events: events, + }, nil +} + +func (s *Server) CountEvents(ctx context.Context, req *pb.CountRequest) (*pb.CountResponse, error) { + events, err := s.store.QueryEvents(ctx, req.Filters, &storage.QueryOptions{Limit: 0}) + if err != nil { + return nil, fmt.Errorf("count failed: %w", err) + } + + return &pb.CountResponse{ + Count: int64(len(events)), + }, nil +} + +func (s *Server) PublishBatch(ctx context.Context, req *pb.PublishBatchRequest) (*pb.PublishBatchResponse, error) { + results := make([]*pb.PublishEventResponse, len(req.Events)) + + for i, event := range req.Events { + resp, err := s.PublishEvent(ctx, &pb.PublishEventRequest{Event: event}) + if err != nil { + return nil, err + } + results[i] = resp + } + + return &pb.PublishBatchResponse{ + Results: results, + }, nil +} + +func (s *Server) Subscribe(req *pb.SubscribeRequest, stream pb.NostrRelay_SubscribeServer) error { + return fmt.Errorf("not implemented yet") +} + +func (s *Server) Unsubscribe(ctx context.Context, req *pb.UnsubscribeRequest) (*pb.Empty, error) { + return nil, fmt.Errorf("not implemented yet") +} diff --git a/internal/handler/grpc/server_test.go b/internal/handler/grpc/server_test.go new file mode 100644 index 0000000..12dde92 --- /dev/null +++ b/internal/handler/grpc/server_test.go @@ -0,0 +1,281 @@ +package grpc + +import ( + "context" + "fmt" + "testing" + "time" + + pb "northwest.io/nostr-grpc/api/nostr/v1" + "northwest.io/nostr-grpc/internal/nostr" + "northwest.io/nostr-grpc/internal/storage" +) + +func TestPublishEvent(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + key, err := nostr.GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + nostrEvent := &nostr.Event{ + PubKey: key.Public(), + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: nostr.Tags{}, + Content: "test event", + } + + if err := key.Sign(nostrEvent); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + + pbEvent := NostrToPB(nostrEvent) + + resp, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}) + if err != nil { + t.Fatalf("PublishEvent failed: %v", err) + } + + if !resp.Accepted { + t.Errorf("expected event to be accepted, got: %s", resp.Message) + } + if resp.Message != "success" { + t.Errorf("expected success message, got: %s", resp.Message) + } + if len(resp.CanonicalJson) == 0 { + t.Error("expected canonical JSON to be returned") + } +} + +func TestPublishEventDuplicate(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + key, err := nostr.GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + nostrEvent := &nostr.Event{ + PubKey: key.Public(), + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: nostr.Tags{}, + Content: "duplicate test", + } + + if err := key.Sign(nostrEvent); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + + pbEvent := NostrToPB(nostrEvent) + + resp1, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}) + if err != nil { + t.Fatalf("first PublishEvent failed: %v", err) + } + if !resp1.Accepted { + t.Fatalf("first event should be accepted") + } + + resp2, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}) + if err != nil { + t.Fatalf("second PublishEvent failed: %v", err) + } + if resp2.Accepted { + t.Error("duplicate event should not be accepted") + } + if resp2.Message != "duplicate: event already exists" { + t.Errorf("expected duplicate message, got: %s", resp2.Message) + } +} + +func TestPublishEventInvalidSignature(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + key, err := nostr.GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + nostrEvent := &nostr.Event{ + PubKey: key.Public(), + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: nostr.Tags{}, + Content: "test event", + } + + if err := key.Sign(nostrEvent); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + + pbEvent := NostrToPB(nostrEvent) + pbEvent.Sig = "invalid_signature" + + resp, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}) + if err != nil { + t.Fatalf("PublishEvent failed: %v", err) + } + + if resp.Accepted { + t.Error("event with invalid signature should not be accepted") + } + if resp.Message != "invalid signature" { + t.Errorf("expected invalid signature message, got: %s", resp.Message) + } +} + +func TestPublishEventInvalidID(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + pbEvent := &pb.Event{ + Id: "wrong_id", + Pubkey: "pubkey123", + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: []*pb.Tag{}, + Content: "test", + Sig: "sig123", + } + + resp, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}) + if err != nil { + t.Fatalf("PublishEvent failed: %v", err) + } + + if resp.Accepted { + t.Error("event with invalid ID should not be accepted") + } + if resp.Message != "invalid event ID" { + t.Errorf("expected invalid ID message, got: %s", resp.Message) + } +} + +func TestQueryEvents(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + key, err := nostr.GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + for i := 0; i < 3; i++ { + nostrEvent := &nostr.Event{ + PubKey: key.Public(), + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: nostr.Tags{}, + Content: fmt.Sprintf("test event %d", i), + } + + if err := key.Sign(nostrEvent); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + + pbEvent := NostrToPB(nostrEvent) + if _, err := server.PublishEvent(ctx, &pb.PublishEventRequest{Event: pbEvent}); err != nil { + t.Fatalf("failed to publish event: %v", err) + } + + time.Sleep(time.Millisecond) + } + + resp, err := server.QueryEvents(ctx, &pb.QueryRequest{ + Filters: []*pb.Filter{ + {Authors: []string{key.Public()}}, + }, + }) + if err != nil { + t.Fatalf("QueryEvents failed: %v", err) + } + + if len(resp.Events) != 3 { + t.Errorf("expected 3 events, got %d", len(resp.Events)) + } +} + +func TestPublishBatch(t *testing.T) { + store, err := storage.New(":memory:") + if err != nil { + t.Fatalf("failed to create storage: %v", err) + } + defer store.Close() + + server := NewServer(store) + ctx := context.Background() + + key, err := nostr.GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + var events []*pb.Event + for i := 0; i < 3; i++ { + nostrEvent := &nostr.Event{ + PubKey: key.Public(), + CreatedAt: time.Now().Unix(), + Kind: 1, + Tags: nostr.Tags{}, + Content: fmt.Sprintf("batch test %d", i), + } + + if err := key.Sign(nostrEvent); err != nil { + t.Fatalf("failed to sign event: %v", err) + } + + events = append(events, NostrToPB(nostrEvent)) + time.Sleep(time.Millisecond) + } + + resp, err := server.PublishBatch(ctx, &pb.PublishBatchRequest{Events: events}) + if err != nil { + t.Fatalf("PublishBatch failed: %v", err) + } + + if len(resp.Results) != 3 { + t.Fatalf("expected 3 results, got %d", len(resp.Results)) + } + + for i, result := range resp.Results { + if !result.Accepted { + t.Errorf("event %d should be accepted, got: %s", i, result.Message) + } + } +} -- cgit v1.2.3