From 56bd30d3d265563938058770c58a80e505de8178 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 05:10:11 +0000 Subject: [PATCH] fix(node): add load-or-create identity helper and TTL-aware deduplication Co-Authored-By: Virgil --- node/identity.go | 42 ++++++++++++++++++++++++++++++++++++++++++ node/identity_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ node/transport.go | 18 ++++++++++++++---- node/transport_test.go | 11 +++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/node/identity.go b/node/identity.go index cb80513..63e8556 100644 --- a/node/identity.go +++ b/node/identity.go @@ -109,6 +109,48 @@ func NewNodeManagerWithPaths(keyPath, configPath string) (*NodeManager, error) { return nm, nil } +// LoadOrCreateIdentity loads the node identity from the default XDG paths or +// generates a new dual-role identity when none exists yet. +func LoadOrCreateIdentity() (*NodeManager, error) { + keyPath, err := xdg.DataFile("lethean-desktop/node/private.key") + if err != nil { + return nil, coreerr.E("LoadOrCreateIdentity", "failed to get key path", err) + } + + configPath, err := xdg.ConfigFile("lethean-desktop/node.json") + if err != nil { + return nil, coreerr.E("LoadOrCreateIdentity", "failed to get config path", err) + } + + return LoadOrCreateIdentityWithPaths(keyPath, configPath) +} + +// LoadOrCreateIdentityWithPaths loads an existing identity from the supplied +// paths or creates a new dual-role identity if no persisted identity exists. +// The generated identity name falls back to the host name, then a stable +// project-specific default if the host name cannot be determined. +func LoadOrCreateIdentityWithPaths(keyPath, configPath string) (*NodeManager, error) { + nm, err := NewNodeManagerWithPaths(keyPath, configPath) + if err != nil { + return nil, err + } + + if nm.HasIdentity() { + return nm, nil + } + + name, err := os.Hostname() + if err != nil || name == "" { + name = "lethean-node" + } + + if err := nm.GenerateIdentity(name, RoleDual); err != nil { + return nil, coreerr.E("LoadOrCreateIdentityWithPaths", "failed to generate identity", err) + } + + return nm, nil +} + // HasIdentity returns true if a node identity has been initialized. func (n *NodeManager) HasIdentity() bool { n.mu.RLock() diff --git a/node/identity_test.go b/node/identity_test.go index ac892df..1653ada 100644 --- a/node/identity_test.go +++ b/node/identity_test.go @@ -215,6 +215,47 @@ func TestNodeIdentity(t *testing.T) { t.Error("should not have identity after delete") } }) + + t.Run("LoadOrCreateIdentityWithPaths", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "node-load-or-create-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + keyPath := filepath.Join(tmpDir, "private.key") + configPath := filepath.Join(tmpDir, "node.json") + + nm, err := LoadOrCreateIdentityWithPaths(keyPath, configPath) + if err != nil { + t.Fatalf("failed to load or create identity: %v", err) + } + + if !nm.HasIdentity() { + t.Fatal("expected identity to be initialised") + } + + identity := nm.GetIdentity() + if identity == nil { + t.Fatal("identity should not be nil") + } + + if identity.Name == "" { + t.Error("identity name should be populated") + } + + if identity.Role != RoleDual { + t.Errorf("expected default role dual, got %s", identity.Role) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("expected private key to be persisted: %v", err) + } + + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected identity config to be persisted: %v", err) + } + }) } func TestNodeRoles(t *testing.T) { diff --git a/node/transport.go b/node/transport.go index e30c0e5..45a529b 100644 --- a/node/transport.go +++ b/node/transport.go @@ -76,10 +76,20 @@ func NewMessageDeduplicator(ttl time.Duration) *MessageDeduplicator { // IsDuplicate checks if a message ID has been seen recently func (d *MessageDeduplicator) IsDuplicate(msgID string) bool { - d.mu.RLock() - _, exists := d.seen[msgID] - d.mu.RUnlock() - return exists + d.mu.Lock() + defer d.mu.Unlock() + + seenAt, exists := d.seen[msgID] + if !exists { + return false + } + + if d.ttl > 0 && time.Since(seenAt) > d.ttl { + delete(d.seen, msgID) + return false + } + + return true } // Mark records a message ID as seen diff --git a/node/transport_test.go b/node/transport_test.go index ffa6e5a..d807c16 100644 --- a/node/transport_test.go +++ b/node/transport_test.go @@ -159,6 +159,17 @@ func TestMessageDeduplicator(t *testing.T) { } }) + t.Run("ExpiredEntriesAreNotDuplicates", func(t *testing.T) { + d := NewMessageDeduplicator(25 * time.Millisecond) + d.Mark("msg-expired") + + time.Sleep(40 * time.Millisecond) + + if d.IsDuplicate("msg-expired") { + t.Error("expired message should not remain a duplicate") + } + }) + t.Run("ConcurrentAccess", func(t *testing.T) { d := NewMessageDeduplicator(5 * time.Minute) var wg sync.WaitGroup