From 67db0889810590495cd92845806fb2c7ced45d8c Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 31 Oct 2025 19:12:21 -0400 Subject: [PATCH] Refactored into namespaced packages. --- README.md | 65 +++++++++++-------- errors/errors.go | 31 +++++++++ event.go | 62 ------------------ events/event.go | 35 ++++++++++ .../event_json_test.go | 2 +- event_test.go => events/event_test.go | 2 +- id.go => events/id.go | 2 +- id_test.go => events/id_test.go | 2 +- sign.go => events/sign.go | 7 +- sign_test.go => events/sign_test.go | 2 +- util_test.go => events/util_test.go | 2 +- validate.go => events/validate.go | 18 ++--- validate_test.go => events/validate_test.go | 2 +- filter.go => filters/filter.go | 7 +- .../filter_json_test.go | 2 +- .../filter_match_test.go | 9 +-- .../testdata}/test_events.json | 0 filters/util_test.go | 5 ++ keys.go => keys/keys.go | 7 +- keys_test.go => keys/keys_test.go | 8 ++- 20 files changed, 150 insertions(+), 120 deletions(-) create mode 100644 errors/errors.go delete mode 100644 event.go create mode 100644 events/event.go rename event_json_test.go => events/event_json_test.go (99%) rename event_test.go => events/event_test.go (98%) rename id.go => events/id.go (98%) rename id_test.go => events/id_test.go (99%) rename sign.go => events/sign.go (85%) rename sign_test.go => events/sign_test.go (98%) rename util_test.go => events/util_test.go (72%) rename validate.go => events/validate.go (88%) rename validate_test.go => events/validate_test.go (99%) rename filter.go => filters/filter.go (97%) rename filter_json_test.go => filters/filter_json_test.go (99%) rename filter_match_test.go => filters/filter_match_test.go (98%) rename {testdata => filters/testdata}/test_events.json (100%) create mode 100644 filters/util_test.go rename keys.go => keys/keys.go (86%) rename keys_test.go => keys/keys_test.go (74%) diff --git a/README.md b/README.md index 0b29ce2..c59a962 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,24 @@ mechanisms, and user interfaces. go get git.wisehodl.dev/jay/go-roots ``` -2. Import it with: +If the primary repository is unavailable, use the `replace` directive in your go.mod file to get the package from the github mirror: -```golang -import "git.wisehodl.dev/jay/go-roots" +``` +replace git.wisehodl.dev/jay/go-roots => github.com/wisehodl/go-roots latest ``` -3. Access it with the `roots` namespace. +2. Import the packages: + +```golang +import ( + "git.wisehodl.dev/jay/go-roots/errors" + "git.wisehodl.dev/jay/go-roots/events" + "git.wisehodl.dev/jay/go-roots/filters" + "git.wisehodl.dev/jay/go-roots/keys" +) +``` + +3. Access functions with appropriate namespaces. ## Usage Examples @@ -46,12 +57,12 @@ import "git.wisehodl.dev/jay/go-roots" #### Generate a new keypair ```go -privateKey, err := roots.GeneratePrivateKey() +privateKey, err := keys.GeneratePrivateKey() if err != nil { log.Fatal(err) } -publicKey, err := roots.GetPublicKey(privateKey) +publicKey, err := keys.GetPublicKey(privateKey) if err != nil { log.Fatal(err) } @@ -61,7 +72,7 @@ if err != nil { ```go privateKey := "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167" -publicKey, err := roots.GetPublicKey(privateKey) +publicKey, err := keys.GetPublicKey(privateKey) // publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" ``` @@ -73,11 +84,11 @@ publicKey, err := roots.GetPublicKey(privateKey) ```go // 1. Build the event structure -event := roots.Event{ +event := events.Event{ PubKey: publicKey, CreatedAt: int(time.Now().Unix()), Kind: 1, - Tags: []roots.Tag{ + Tags: []events.Tag{ {"e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}, {"p", "91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}, }, @@ -92,7 +103,7 @@ if err != nil { event.ID = id // 3. Sign the event -sig, err := roots.SignEvent(id, privateKey) +sig, err := events.SignEvent(id, privateKey) if err != nil { log.Fatal(err) } @@ -168,7 +179,7 @@ if err != nil { #### Unmarshal event from JSON ```go -var event roots.Event +var event events.Event err := json.Unmarshal(jsonBytes, &event) if err != nil { log.Fatal(err) @@ -190,7 +201,7 @@ if err := event.Validate(); err != nil { since := int(time.Now().Add(-24 * time.Hour).Unix()) limit := 50 -filter := roots.Filter{ +filter := filters.Filter{ IDs: []string{"abc123", "def456"}, // Prefix match Authors: []string{"cfa87f35"}, // Prefix match Kinds: []int{1, 6, 7}, @@ -202,9 +213,9 @@ filter := roots.Filter{ #### Filter with tag conditions ```go -filter := roots.Filter{ +filter := filters.Filter{ Kinds: []int{1}, - Tags: roots.TagFilters{ + Tags: filters.TagFilters{ "e": {"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"}, "p": {"91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"}, }, @@ -216,9 +227,9 @@ filter := roots.Filter{ ```go // Extensions allow arbitrary JSON fields beyond the standard filter spec. // For example, this is how to implement non-standard filters like 'search'. -filter := roots.Filter{ +filter := filters.Filter{ Kinds: []int{1}, - Extensions: roots.FilterExtensions{ + Extensions: filters.FilterExtensions{ "search": json.RawMessage(`"bitcoin"`), }, } @@ -234,7 +245,7 @@ filter := roots.Filter{ #### Match single event ```go -filter := roots.Filter{ +filter := filters.Filter{ Authors: []string{"cfa87f35"}, Kinds: []int{1}, } @@ -248,15 +259,15 @@ if filter.Matches(&event) { ```go since := int(time.Now().Add(-1 * time.Hour).Unix()) -filter := roots.Filter{ +filter := filters.Filter{ Kinds: []int{1}, Since: &since, - Tags: roots.TagFilters{ + Tags: filters.TagFilters{ "p": {"abc123", "def456"}, // OR within tag values }, } -var matches []roots.Event +var matches []events.Event for _, event := range events { if filter.Matches(&event) { matches = append(matches, event) @@ -271,13 +282,13 @@ for _, event := range events { #### Marshal filter to JSON ```go -filter := roots.Filter{ +filter := filters.Filter{ IDs: []string{"abc123"}, Kinds: []int{1}, - Tags: roots.TagFilters{ + Tags: filters.TagFilters{ "e": {"event-id"}, }, - Extensions: roots.FilterExtensions{ + Extensions: filters.FilterExtensions{ "search": json.RawMessage(`"nostr"`), }, } @@ -297,7 +308,7 @@ jsonData := `{ "search": "bitcoin" }` -var filter roots.Filter +var filter filters.Filter err := filter.UnmarshalJSON([]byte(jsonData)) if err != nil { log.Fatal(err) @@ -323,9 +334,9 @@ During marshaling, Extensions merge into the output JSON. During unmarshaling, u Example implementing search filter: ```go -filter := roots.Filter{ +filter := filters.Filter{ Kinds: []int{1}, - Extensions: roots.FilterExtensions{ + Extensions: filters.FilterExtensions{ "search": json.RawMessage(`"bitcoin"`), }, } @@ -343,5 +354,5 @@ if searchRaw, ok := filter.Extensions["search"]; ok { This library contains a comprehensive suite of unit tests. Run them with: ```bash -go test +go test ./... ``` diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..101915c --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,31 @@ +package errors + +import ( + "errors" +) + +var ( + // MalformedPubKey indicates a public key is not 64 lowercase hex characters. + MalformedPubKey = errors.New("public key must be 64 lowercase hex characters") + + // MalformedPrivKey indicates a private key is not 64 lowercase hex characters. + MalformedPrivKey = errors.New("private key must be 64 lowercase hex characters") + + // MalformedID indicates an event id is not 64 hex characters. + MalformedID = errors.New("event id must be 64 hex characters") + + // MalformedSig indicates an event signature is not 128 hex characters. + MalformedSig = errors.New("event signature must be 128 hex characters") + + // MalformedTag indicates an event tag contains fewer than two elements. + MalformedTag = errors.New("tags must contain at least two elements") + + // FailedIDComp indicates the event ID could not be computed during validation. + FailedIDComp = errors.New("failed to compute event id") + + // NoEventID indicates the event ID field is empty. + NoEventID = errors.New("event id is empty") + + // InvalidSig indicates the event signature failed cryptographic validation. + InvalidSig = errors.New("event signature is invalid") +) diff --git a/event.go b/event.go deleted file mode 100644 index 96ff025..0000000 --- a/event.go +++ /dev/null @@ -1,62 +0,0 @@ -// Roots is a purposefully minimal Nostr protocol library that provides only -// the primitives that define protocol compliance: event structure, -// serialization, cryptographic signatures, and subscription filters. -package roots - -import ( - "errors" - "regexp" -) - -// Tag represents a single tag within an event as an array of strings. -// The first element identifies the tag name, the second contains the value, -// and subsequent elements are optional. -type Tag []string - -// Event represents a Nostr protocol event, with its seven required fields. -// All fields must be present for a valid event. -type Event struct { - ID string `json:"id"` - PubKey string `json:"pubkey"` - CreatedAt int `json:"created_at"` - Kind int `json:"kind"` - Tags []Tag `json:"tags"` - Content string `json:"content"` - Sig string `json:"sig"` -} - -var ( - // Hex64Pattern matches 64-character, lowercase, hexadecimal strings. - // Used for validating event IDs and cryptographic keys. - Hex64Pattern = regexp.MustCompile("^[a-f0-9]{64}$") - - // Hex128Pattern matches 128-character, lowercase, hexadecimal strings. - // Used for validating signatures. - Hex128Pattern = regexp.MustCompile("^[a-f0-9]{128}$") -) - -var ( - // ErrMalformedPubKey indicates a public key is not 64 lowercase hex characters. - ErrMalformedPubKey = errors.New("public key must be 64 lowercase hex characters") - - // ErrMalformedPrivKey indicates a private key is not 64 lowercase hex characters. - ErrMalformedPrivKey = errors.New("private key must be 64 lowercase hex characters") - - // ErrMalformedID indicates an event id is not 64 hex characters. - ErrMalformedID = errors.New("event id must be 64 hex characters") - - // ErrMalformedSig indicates an event signature is not 128 hex characters. - ErrMalformedSig = errors.New("event signature must be 128 hex characters") - - // ErrMalformedTag indicates an event tag contains fewer than two elements. - ErrMalformedTag = errors.New("tags must contain at least two elements") - - // ErrFailedIDComp indicates the event ID could not be computed during validation. - ErrFailedIDComp = errors.New("failed to compute event id") - - // ErrNoEventID indicates the event ID field is empty. - ErrNoEventID = errors.New("event id is empty") - - // ErrInvalidSig indicates the event signature failed cryptographic validation. - ErrInvalidSig = errors.New("event signature is invalid") -) diff --git a/events/event.go b/events/event.go new file mode 100644 index 0000000..245f33d --- /dev/null +++ b/events/event.go @@ -0,0 +1,35 @@ +// Roots is a purposefully minimal Nostr protocol library that provides only +// the primitives that define protocol compliance: event structure, +// serialization, cryptographic signatures, and subscription filters. +package events + +import ( + "regexp" +) + +// Tag represents a single tag within an event as an array of strings. +// The first element identifies the tag name, the second contains the value, +// and subsequent elements are optional. +type Tag []string + +// Event represents a Nostr protocol event, with its seven required fields. +// All fields must be present for a valid event. +type Event struct { + ID string `json:"id"` + PubKey string `json:"pubkey"` + CreatedAt int `json:"created_at"` + Kind int `json:"kind"` + Tags []Tag `json:"tags"` + Content string `json:"content"` + Sig string `json:"sig"` +} + +var ( + // Hex64Pattern matches 64-character, lowercase, hexadecimal strings. + // Used for validating event IDs and cryptographic keys. + Hex64Pattern = regexp.MustCompile("^[a-f0-9]{64}$") + + // Hex128Pattern matches 128-character, lowercase, hexadecimal strings. + // Used for validating signatures. + Hex128Pattern = regexp.MustCompile("^[a-f0-9]{128}$") +) diff --git a/event_json_test.go b/events/event_json_test.go similarity index 99% rename from event_json_test.go rename to events/event_json_test.go index 73e0b6f..121b372 100644 --- a/event_json_test.go +++ b/events/event_json_test.go @@ -1,4 +1,4 @@ -package roots +package events import ( "encoding/json" diff --git a/event_test.go b/events/event_test.go similarity index 98% rename from event_test.go rename to events/event_test.go index 9b26ed3..5be72c5 100644 --- a/event_test.go +++ b/events/event_test.go @@ -1,4 +1,4 @@ -package roots +package events import ( "github.com/stretchr/testify/assert" diff --git a/id.go b/events/id.go similarity index 98% rename from id.go rename to events/id.go index a98fa46..f2aebc8 100644 --- a/id.go +++ b/events/id.go @@ -1,4 +1,4 @@ -package roots +package events import ( "crypto/sha256" diff --git a/id_test.go b/events/id_test.go similarity index 99% rename from id_test.go rename to events/id_test.go index 28b5910..4475d3f 100644 --- a/id_test.go +++ b/events/id_test.go @@ -1,4 +1,4 @@ -package roots +package events import ( "github.com/stretchr/testify/assert" diff --git a/sign.go b/events/sign.go similarity index 85% rename from sign.go rename to events/sign.go index a970959..8808f3a 100644 --- a/sign.go +++ b/events/sign.go @@ -1,8 +1,9 @@ -package roots +package events import ( "encoding/hex" "fmt" + "git.wisehodl.dev/jay/go-roots/errors" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" ) @@ -12,12 +13,12 @@ import ( func SignEvent(eventID, privateKeyHex string) (string, error) { skBytes, err := hex.DecodeString(privateKeyHex) if err != nil { - return "", ErrMalformedPrivKey + return "", errors.MalformedPrivKey } idBytes, err := hex.DecodeString(eventID) if err != nil { - return "", ErrMalformedID + return "", errors.MalformedID } // discard public key return value diff --git a/sign_test.go b/events/sign_test.go similarity index 98% rename from sign_test.go rename to events/sign_test.go index 125181d..7862e95 100644 --- a/sign_test.go +++ b/events/sign_test.go @@ -1,4 +1,4 @@ -package roots +package events import ( "github.com/stretchr/testify/assert" diff --git a/util_test.go b/events/util_test.go similarity index 72% rename from util_test.go rename to events/util_test.go index 7d97194..bf464d9 100644 --- a/util_test.go +++ b/events/util_test.go @@ -1,4 +1,4 @@ -package roots +package events func intPtr(i int) *int { return &i diff --git a/validate.go b/events/validate.go similarity index 88% rename from validate.go rename to events/validate.go index c185e8c..a44afab 100644 --- a/validate.go +++ b/events/validate.go @@ -1,9 +1,9 @@ -package roots +package events import ( "encoding/hex" "fmt" - + "git.wisehodl.dev/jay/go-roots/errors" "github.com/btcsuite/btcd/btcec/v2/schnorr" ) @@ -25,20 +25,20 @@ func (e *Event) Validate() error { // specification: hex lengths, tag structure, and field formats. func (e *Event) ValidateStructure() error { if !Hex64Pattern.MatchString(e.PubKey) { - return ErrMalformedPubKey + return errors.MalformedPubKey } if !Hex64Pattern.MatchString(e.ID) { - return ErrMalformedID + return errors.MalformedID } if !Hex128Pattern.MatchString(e.Sig) { - return ErrMalformedSig + return errors.MalformedSig } for _, tag := range e.Tags { if len(tag) < 2 { - return ErrMalformedTag + return errors.MalformedTag } } @@ -49,10 +49,10 @@ func (e *Event) ValidateStructure() error { func (e *Event) ValidateID() error { computedID, err := e.GetID() if err != nil { - return ErrFailedIDComp + return errors.FailedIDComp } if e.ID == "" { - return ErrNoEventID + return errors.NoEventID } if computedID != e.ID { return fmt.Errorf("event id %q does not match computed id %q", e.ID, computedID) @@ -91,6 +91,6 @@ func (e *Event) ValidateSignature() error { if signature.Verify(idBytes, publicKey) { return nil } else { - return ErrInvalidSig + return errors.InvalidSig } } diff --git a/validate_test.go b/events/validate_test.go similarity index 99% rename from validate_test.go rename to events/validate_test.go index a5b9964..75bf19c 100644 --- a/validate_test.go +++ b/events/validate_test.go @@ -1,4 +1,4 @@ -package roots +package events import ( "github.com/stretchr/testify/assert" diff --git a/filter.go b/filters/filter.go similarity index 97% rename from filter.go rename to filters/filter.go index b83a6d0..8031950 100644 --- a/filter.go +++ b/filters/filter.go @@ -1,7 +1,8 @@ -package roots +package filters import ( "encoding/json" + "git.wisehodl.dev/jay/go-roots/events" "strings" ) @@ -181,7 +182,7 @@ func (f *Filter) UnmarshalJSON(data []byte) error { // Matches returns true if the event satisfies all filter conditions. // Supports prefix matching for IDs and authors, and tag filtering. // Does not account for custom extensions. -func (f *Filter) Matches(event *Event) bool { +func (f *Filter) Matches(event *events.Event) bool { // Check ID if len(f.IDs) > 0 { if !matchesPrefix(event.ID, f.IDs) { @@ -246,7 +247,7 @@ func matchesTimeRange(timestamp int, since *int, until *int) bool { return true } -func matchesTags(eventTags []Tag, tagFilters *TagFilters) bool { +func matchesTags(eventTags []events.Tag, tagFilters *TagFilters) bool { // Build index of tags and values eventIndex := make(map[string][]string, len(eventTags)) for _, tag := range eventTags { diff --git a/filter_json_test.go b/filters/filter_json_test.go similarity index 99% rename from filter_json_test.go rename to filters/filter_json_test.go index fadb16a..ae27d4d 100644 --- a/filter_json_test.go +++ b/filters/filter_json_test.go @@ -1,4 +1,4 @@ -package roots +package filters import ( "encoding/json" diff --git a/filter_match_test.go b/filters/filter_match_test.go similarity index 98% rename from filter_match_test.go rename to filters/filter_match_test.go index 58261a9..227d8d6 100644 --- a/filter_match_test.go +++ b/filters/filter_match_test.go @@ -1,13 +1,14 @@ -package roots +package filters import ( "encoding/json" + "git.wisehodl.dev/jay/go-roots/events" "github.com/stretchr/testify/assert" "os" "testing" ) -var testEvents []Event +var testEvents []events.Event func init() { data, err := os.ReadFile("testdata/test_events.json") @@ -403,8 +404,8 @@ func TestEventFilterMatching(t *testing.T) { // TestEventFilterMatchingSkipMalformedTags documents that filter.Matches() // skips malformed tags during tag matching func TestEventFilterMatchingSkipMalformedTags(t *testing.T) { - event := Event{ - Tags: []Tag{ + event := events.Event{ + Tags: []events.Tag{ {"malformed"}, {"valid", "value"}, }, diff --git a/testdata/test_events.json b/filters/testdata/test_events.json similarity index 100% rename from testdata/test_events.json rename to filters/testdata/test_events.json diff --git a/filters/util_test.go b/filters/util_test.go new file mode 100644 index 0000000..0d406d8 --- /dev/null +++ b/filters/util_test.go @@ -0,0 +1,5 @@ +package filters + +func intPtr(i int) *int { + return &i +} diff --git a/keys.go b/keys/keys.go similarity index 86% rename from keys.go rename to keys/keys.go index c2c8d48..be314ad 100644 --- a/keys.go +++ b/keys/keys.go @@ -1,7 +1,8 @@ -package roots +package keys import ( "encoding/hex" + "git.wisehodl.dev/jay/go-roots/errors" "github.com/decred/dcrd/dcrec/secp256k1/v4" ) @@ -20,11 +21,11 @@ func GeneratePrivateKey() (string, error) { // and returns the x-coordinate as 64 lowercase hex characters. func GetPublicKey(privateKeyHex string) (string, error) { if len(privateKeyHex) != 64 { - return "", ErrMalformedPrivKey + return "", errors.MalformedPrivKey } skBytes, err := hex.DecodeString(privateKeyHex) if err != nil { - return "", ErrMalformedPrivKey + return "", errors.MalformedPrivKey } pk := secp256k1.PrivKeyFromBytes(skBytes).PubKey() diff --git a/keys_test.go b/keys/keys_test.go similarity index 74% rename from keys_test.go rename to keys/keys_test.go index d60705c..6b784ef 100644 --- a/keys_test.go +++ b/keys/keys_test.go @@ -1,10 +1,16 @@ -package roots +package keys import ( "github.com/stretchr/testify/assert" + "regexp" "testing" ) +const testSK = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167" +const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" + +var Hex64Pattern = regexp.MustCompile("^[a-f0-9]{64}$") + func TestGeneratePrivateKey(t *testing.T) { sk, err := GeneratePrivateKey()