From 25928b10c162ebbc42ad50a4d509cab6c268d258 Mon Sep 17 00:00:00 2001 From: Siphiwe -- npub1ms9ujlulcgtpqn2uzpvhplee9l5kjg8jgqhrwmgutg0n7xk43nqq07qa0v Date: Mon, 9 Mar 2026 15:29:13 +0200 Subject: [PATCH 1/3] feat: Add follow-gated push notifications with lazy TTL cache Only send push notifications to users from authors they follow. Includes proactive cache warming from incoming kind 3 events, bounded cache with oldest-10% eviction, and configurable TTL. Kind 1059 (DMs) and test notifications are exempt from gating. Users without a local contact list receive all notifications. --- config.example.yaml | 3 + lib/config/config.go | 6 + lib/types/push.go | 3 + services/push/payload_test.go | 74 +++++++++++- services/push/service.go | 214 ++++++++++++++++++++++++++++++---- 5 files changed, 276 insertions(+), 24 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 14cc081..3bb3ce5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -238,6 +238,9 @@ push_notifications: project_id: "" service: batch_size: 100 + follow_cache_size: 500 + follow_cache_ttl: 5m + follow_gated: true queue_size: 1000 retry_attempts: 3 retry_delay: 5s diff --git a/lib/config/config.go b/lib/config/config.go index 04cd3ad..93671f1 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -827,6 +827,9 @@ func setDefaults() { viper.SetDefault("push_notifications.service.batch_size", 100) viper.SetDefault("push_notifications.service.retry_attempts", 3) viper.SetDefault("push_notifications.service.retry_delay", "5s") + viper.SetDefault("push_notifications.service.follow_gated", true) + viper.SetDefault("push_notifications.service.follow_cache_size", 500) + viper.SetDefault("push_notifications.service.follow_cache_ttl", "5m") } // GetAllSettingsAsMap returns all configuration settings as a map @@ -961,6 +964,9 @@ func GetAllSettingsAsMap() (map[string]interface{}, error) { "batch_size": cfg.PushNotifications.Service.BatchSize, "retry_attempts": cfg.PushNotifications.Service.RetryAttempts, "retry_delay": cfg.PushNotifications.Service.RetryDelay, + "follow_gated": cfg.PushNotifications.Service.FollowGated, + "follow_cache_size": cfg.PushNotifications.Service.FollowCacheSize, + "follow_cache_ttl": cfg.PushNotifications.Service.FollowCacheTTL, }, } diff --git a/lib/types/push.go b/lib/types/push.go index 08b0a65..8a1dbe6 100644 --- a/lib/types/push.go +++ b/lib/types/push.go @@ -61,4 +61,7 @@ type PushServiceConfig struct { BatchSize int `mapstructure:"batch_size"` RetryAttempts int `mapstructure:"retry_attempts"` RetryDelay string `mapstructure:"retry_delay"` + FollowGated bool `mapstructure:"follow_gated"` + FollowCacheSize int `mapstructure:"follow_cache_size"` + FollowCacheTTL string `mapstructure:"follow_cache_ttl"` } diff --git a/services/push/payload_test.go b/services/push/payload_test.go index 1d7f88c..720df95 100644 --- a/services/push/payload_test.go +++ b/services/push/payload_test.go @@ -59,11 +59,16 @@ func (m *mockPushStore) GetStatsStore() statistics.StatisticsStore { return nil } +// newTestPushService creates a PushService with a mock store for testing // newTestPushService creates a PushService with a mock store for testing func newTestPushService(events []*nostr.Event) *PushService { return &PushService{ - store: &mockPushStore{events: events}, - nameCache: make(map[string]string), + store: &mockPushStore{events: events}, + nameCache: make(map[string]string), + followCache: make(map[string]*followCacheEntry), + followCacheTTL: 5 * time.Minute, + followCacheMax: 500, + followGated: false, // disabled by default in tests } } @@ -421,4 +426,69 @@ func logPayload(t *testing.T, label string, message *PushMessage) { payload := message.ToAPNsPayload() payloadJSON, _ := json.MarshalIndent(payload, "", " ") t.Logf("\n๐Ÿ“ฑ %s APNs Payload:\n%s", label, string(payloadJSON)) +} + +// TestFollowGate_BlocksNonFollower verifies that notifications are blocked +// when the recipient does not follow the event author. +func TestFollowGate_BlocksNonFollower(t *testing.T) { + // Recipient's contact list (kind 3) โ€” follows only "friend_pubkey" + contactList := &nostr.Event{ + ID: "contact_list_123", + PubKey: "recipient_pubkey", + Kind: 3, + Tags: nostr.Tags{{"p", "friend_pubkey"}}, + CreatedAt: nostr.Timestamp(time.Now().Unix()), + } + + ps := newTestPushService([]*nostr.Event{contactList}) + ps.followGated = true + + // Event from someone the recipient follows โ€” should be allowed + friendEvent := &nostr.Event{Kind: 1, PubKey: "friend_pubkey"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "friend_pubkey", friendEvent) { + t.Error("Expected notification to be allowed for followed author") + } + + // Event from someone the recipient does NOT follow โ€” should be blocked + strangerEvent := &nostr.Event{Kind: 1, PubKey: "stranger_pubkey"} + if ps.recipientFollowsAuthor("recipient_pubkey", "stranger_pubkey", strangerEvent) { + t.Error("Expected notification to be blocked for non-followed author") + } +} + +// TestFollowGate_AllowsKind1059 verifies that encrypted DMs (Gift Wrap) +// bypass the follow gate since the pubkey is ephemeral. +func TestFollowGate_AllowsKind1059(t *testing.T) { + ps := newTestPushService(nil) + ps.followGated = true + + giftWrap := &nostr.Event{Kind: 1059, PubKey: "ephemeral_key"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "ephemeral_key", giftWrap) { + t.Error("Expected kind 1059 to bypass follow gate") + } +} + +// TestFollowGate_AllowsTestNotification verifies that test notifications +// (all-zeros pubkey) bypass the follow gate. +func TestFollowGate_AllowsTestNotification(t *testing.T) { + ps := newTestPushService(nil) + ps.followGated = true + + testEvent := &nostr.Event{Kind: 1808, PubKey: "0000000000000000000000000000000000000000000000000000000000000000"} + if !ps.recipientFollowsAuthor("recipient_pubkey", testEvent.PubKey, testEvent) { + t.Error("Expected test notification to bypass follow gate") + } +} + +// TestFollowGate_AllowsNoContactList verifies that users with no contact list +// still receive all notifications (permissive for new users). +func TestFollowGate_AllowsNoContactList(t *testing.T) { + // No events in store โ€” recipient has no kind 3 + ps := newTestPushService(nil) + ps.followGated = true + + event := &nostr.Event{Kind: 1, PubKey: "some_author"} + if !ps.recipientFollowsAuthor("recipient_pubkey", "some_author", event) { + t.Error("Expected notification to be allowed when recipient has no contact list") + } } \ No newline at end of file diff --git a/services/push/service.go b/services/push/service.go index b29afb5..15eac84 100644 --- a/services/push/service.go +++ b/services/push/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/HORNET-Storage/hornet-storage/lib/config" "github.com/HORNET-Storage/hornet-storage/lib/logging" @@ -13,23 +14,38 @@ import ( "github.com/nbd-wtf/go-nostr" ) +const ( + defaultFollowCacheSize = 500 + defaultFollowCacheTTL = 5 * time.Minute +) + +// followCacheEntry stores a user's follow set with expiry +type followCacheEntry struct { + follows map[string]bool // set of pubkeys this user follows; nil = no contact list found + cachedAt time.Time +} // PushService manages push notifications type PushService struct { - store stores.Store - config *types.PushNotificationConfig - queue chan *NotificationTask - workers []*Worker - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - mutex sync.RWMutex - apnsClient APNSClient - fcmClient FCMClient - isRunning bool - nameCache map[string]string // Cache for author names (pubkey -> name) - cacheMutex sync.RWMutex // Mutex for cache access - processedIDs map[string]bool // Track processed event IDs to prevent duplicates - idMutex sync.RWMutex // Mutex for processed IDs + store stores.Store + config *types.PushNotificationConfig + queue chan *NotificationTask + workers []*Worker + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mutex sync.RWMutex + apnsClient APNSClient + fcmClient FCMClient + isRunning bool + nameCache map[string]string // Cache for author names (pubkey -> name) + cacheMutex sync.RWMutex // Mutex for cache access + processedIDs map[string]bool // Track processed event IDs to prevent duplicates + idMutex sync.RWMutex // Mutex for processed IDs + followCache map[string]*followCacheEntry // recipientPubkey -> follow set + followMutex sync.RWMutex // Mutex for follow cache + followCacheTTL time.Duration // TTL for follow cache entries + followCacheMax int // Max entries in follow cache + followGated bool // Whether follow-gating is enabled } // NotificationTask represents a push notification task @@ -85,14 +101,30 @@ func NewPushService(store stores.Store) (*PushService, error) { ctx, cancel := context.WithCancel(context.Background()) + // Parse follow cache TTL + followTTL := defaultFollowCacheTTL + if cfg.PushNotifications.Service.FollowCacheTTL != "" { + if parsed, err := time.ParseDuration(cfg.PushNotifications.Service.FollowCacheTTL); err == nil { + followTTL = parsed + } + } + followMax := cfg.PushNotifications.Service.FollowCacheSize + if followMax <= 0 { + followMax = defaultFollowCacheSize + } + service := &PushService{ - store: store, - config: &cfg.PushNotifications, - queue: make(chan *NotificationTask, cfg.PushNotifications.Service.QueueSize), - ctx: ctx, - cancel: cancel, - nameCache: make(map[string]string), - processedIDs: make(map[string]bool), + store: store, + config: &cfg.PushNotifications, + queue: make(chan *NotificationTask, cfg.PushNotifications.Service.QueueSize), + ctx: ctx, + cancel: cancel, + nameCache: make(map[string]string), + processedIDs: make(map[string]bool), + followCache: make(map[string]*followCacheEntry), + followCacheTTL: followTTL, + followCacheMax: followMax, + followGated: cfg.PushNotifications.Service.FollowGated, } // Initialize APNs client if enabled @@ -198,6 +230,12 @@ func (ps *PushService) ProcessEvent(event *nostr.Event) { logging.Infof("๐Ÿ”” Processing event for push notifications - Kind: %d, Event ID: %s, Author: %s", event.Kind, event.ID, event.PubKey) + // Proactively warm follow cache when kind 3 events arrive + // (kind 3 is disabled for push notifications, but we still use it to update the follow cache) + if event.Kind == 3 && ps.followGated { + ps.warmFollowCacheFromEvent(event) + } + // Check if this event type should trigger notifications if !ps.shouldNotify(event) { logging.Debugf("Event kind %d does not trigger notifications", event.Kind) @@ -214,6 +252,13 @@ func (ps *PushService) ProcessEvent(event *nostr.Event) { continue } + // Follow-gate: skip notification if recipient doesn't follow the author + if ps.followGated && !ps.recipientFollowsAuthor(pubkey, event.PubKey, event) { + logging.Debugf("โญ๏ธ Skipping notification: %s does not follow author %s", + shortenPubkey(pubkey), shortenPubkey(event.PubKey)) + continue + } + // Get devices for this user // Need to get StatsStore first statsStore := ps.store.GetStatsStore() @@ -694,6 +739,131 @@ func (ps *PushService) formatNotificationMessage(event *nostr.Event, recipient s return message } +// recipientFollowsAuthor checks if the recipient follows the event author. +// Returns true (allow notification) if: +// - The event kind is exempt from follow-gating (kind 1059, test notifications) +// - The recipient has no contact list (permissive for new users) +// - The recipient's contact list contains the author +func (ps *PushService) recipientFollowsAuthor(recipientPubkey, authorPubkey string, event *nostr.Event) bool { + // Exempt: test notifications (all-zeros pubkey used by test handler) + if authorPubkey == "0000000000000000000000000000000000000000000000000000000000000000" { + return true + } + // Exempt: Gift Wrap DMs (kind 1059) โ€” pubkey is ephemeral, no one "follows" it + if event.Kind == 1059 { + return true + } + + // Check cache + ps.followMutex.RLock() + if entry, ok := ps.followCache[recipientPubkey]; ok { + if time.Since(entry.cachedAt) < ps.followCacheTTL { + ps.followMutex.RUnlock() + if entry.follows == nil { + return true // No contact list = permissive + } + return entry.follows[authorPubkey] + } + } + ps.followMutex.RUnlock() + + // Cache miss โ€” query store for recipient's kind 3 event + follows := ps.loadFollowSet(recipientPubkey) + + // Store in cache + ps.followMutex.Lock() + ps.followCache[recipientPubkey] = &followCacheEntry{ + follows: follows, + cachedAt: time.Now(), + } + if len(ps.followCache) > ps.followCacheMax { + ps.evictOldestFollowEntries() + } + ps.followMutex.Unlock() + + if follows == nil { + return true // No contact list = permissive (don't block new users) + } + return follows[authorPubkey] +} + +// loadFollowSet queries the store for a user's kind 3 contact list and +// returns a set of followed pubkeys. Returns nil if no contact list exists. +func (ps *PushService) loadFollowSet(pubkey string) map[string]bool { + filter := nostr.Filter{ + Authors: []string{pubkey}, + Kinds: []int{3}, + Limit: 1, + } + events, err := ps.store.QueryEvents(filter) + if err != nil || len(events) == 0 { + return nil // nil = no contact list found + } + + follows := make(map[string]bool) + for _, tag := range events[0].Tags { + if len(tag) >= 2 && tag[0] == "p" { + follows[tag[1]] = true + } + } + return follows +} + +// warmFollowCacheFromEvent updates the follow cache directly from a kind 3 event. +// Called when ProcessEvent receives a kind 3 event (before shouldNotify filters it out). +func (ps *PushService) warmFollowCacheFromEvent(event *nostr.Event) { + follows := make(map[string]bool) + for _, tag := range event.Tags { + if len(tag) >= 2 && tag[0] == "p" { + follows[tag[1]] = true + } + } + + ps.followMutex.Lock() + ps.followCache[event.PubKey] = &followCacheEntry{ + follows: follows, + cachedAt: time.Now(), + } + ps.followMutex.Unlock() + + logging.Debugf("๐Ÿ”„ Warmed follow cache for %s (%d follows)", shortenPubkey(event.PubKey), len(follows)) +} + +// evictOldestFollowEntries removes the oldest 10% of follow cache entries. +// Must be called with followMutex held for writing. +func (ps *PushService) evictOldestFollowEntries() { + type keyAge struct { + key string + cachedAt time.Time + } + + itemsToRemove := ps.followCacheMax / 10 + if itemsToRemove < 1 { + itemsToRemove = 1 + } + + items := make([]keyAge, 0, len(ps.followCache)) + for k, v := range ps.followCache { + items = append(items, keyAge{k, v.cachedAt}) + } + + // Selection sort to find oldest N items to remove + for i := 0; i < itemsToRemove && i < len(items); i++ { + oldest := i + for j := i + 1; j < len(items); j++ { + if items[j].cachedAt.Before(items[oldest].cachedAt) { + oldest = j + } + } + if oldest != i { + items[i], items[oldest] = items[oldest], items[i] + } + delete(ps.followCache, items[i].key) + } + + logging.Debugf("๐Ÿงน Evicted %d oldest follow cache entries", itemsToRemove) +} + // Global service instance var globalPushService *PushService var serviceMutex sync.RWMutex From b3d98dbe20f653a1f0a4009cad02806839df20a1 Mon Sep 17 00:00:00 2001 From: Siphiwe -- npub1ms9ujlulcgtpqn2uzpvhplee9l5kjg8jgqhrwmgutg0n7xk43nqq07qa0v Date: Mon, 9 Mar 2026 17:11:59 +0200 Subject: [PATCH 2/3] fix: Add NIP-01 replaceable event timestamp checks to kind 0 and kind 3 handlers Kind 0 (profile) and kind 3 (contact list) handlers were blindly deleting existing events without checking timestamps, allowing older events to overwrite newer ones. Now both handlers compare CreatedAt timestamps and reject incoming events that are older than what's already stored. Also hardens the follow cache warming in push service to check the store for a newer kind 3 before warming, preventing stale contact lists from polluting the cache. --- lib/handlers/nostr/kind0/kind0handler.go | 7 ++++++- lib/handlers/nostr/kind3/kind3handler.go | 8 ++++++-- services/push/service.go | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/handlers/nostr/kind0/kind0handler.go b/lib/handlers/nostr/kind0/kind0handler.go index c5f44cc..0e6dde1 100644 --- a/lib/handlers/nostr/kind0/kind0handler.go +++ b/lib/handlers/nostr/kind0/kind0handler.go @@ -134,9 +134,14 @@ func BuildKind0Handler(store stores.Store, relayPrivKey *btcec.PrivateKey) func( return } - // Delete existing kind 0 events if any + // Delete existing kind 0 events if any (NIP-01 replaceable event semantics) if len(existingEvents) > 0 { for _, oldEvent := range existingEvents { + if oldEvent.CreatedAt > env.Event.CreatedAt { + // Existing event is newer โ€” reject the incoming event + write("OK", env.Event.ID, false, "blocked: existing profile is newer") + return + } if err := store.DeleteEvent(oldEvent.ID); err != nil { logging.Infof("Error deleting old kind 0 event %s: %v", oldEvent.ID, err) write("NOTICE", "Error deleting old kind 0 event %s: %v", oldEvent.ID, err) diff --git a/lib/handlers/nostr/kind3/kind3handler.go b/lib/handlers/nostr/kind3/kind3handler.go index d734f57..1f8cdac 100644 --- a/lib/handlers/nostr/kind3/kind3handler.go +++ b/lib/handlers/nostr/kind3/kind3handler.go @@ -47,12 +47,16 @@ func BuildKind3Handler(store stores.Store) func(read lib_nostr.KindReader, write return } - // If there's an existing event, delete it + // If there are existing events, check timestamps (NIP-01 replaceable event semantics) if len(existingEvents) > 0 { for _, oldEvent := range existingEvents { + if oldEvent.CreatedAt > env.Event.CreatedAt { + // Existing event is newer โ€” reject the incoming event + write("OK", env.Event.ID, false, "blocked: existing contact list is newer") + return + } if err := store.DeleteEvent(oldEvent.ID); err != nil { logging.Infof("Error deleting old contact list event %s: %v", oldEvent.ID, err) - // Decide how to handle delete failures } } } diff --git a/services/push/service.go b/services/push/service.go index 15eac84..e18d0e8 100644 --- a/services/push/service.go +++ b/services/push/service.go @@ -811,7 +811,21 @@ func (ps *PushService) loadFollowSet(pubkey string) map[string]bool { // warmFollowCacheFromEvent updates the follow cache directly from a kind 3 event. // Called when ProcessEvent receives a kind 3 event (before shouldNotify filters it out). +// Checks the store first to avoid overwriting the cache with a stale contact list. func (ps *PushService) warmFollowCacheFromEvent(event *nostr.Event) { + // Don't warm cache with stale data โ€” check if store already has a newer kind 3 + existingFilter := nostr.Filter{ + Authors: []string{event.PubKey}, + Kinds: []int{3}, + Limit: 1, + } + if existing, err := ps.store.QueryEvents(existingFilter); err == nil && len(existing) > 0 { + if existing[0].CreatedAt > event.CreatedAt { + logging.Debugf("๐Ÿ”„ Skipping cache warm for %s โ€” store has newer kind 3", shortenPubkey(event.PubKey)) + return + } + } + follows := make(map[string]bool) for _, tag := range event.Tags { if len(tag) >= 2 && tag[0] == "p" { From ec7a1a20f9e0653f486709b6b9e1830f994fbf88 Mon Sep 17 00:00:00 2001 From: Siphiwe -- npub1ms9ujlulcgtpqn2uzpvhplee9l5kjg8jgqhrwmgutg0n7xk43nqq07qa0v Date: Mon, 9 Mar 2026 20:20:46 +0200 Subject: [PATCH 3/3] chore: Default follow_gated to false for early-stage growth With low traffic, all notifications should reach users to keep the app feeling active. When traffic grows and spam becomes a concern, operators can enable follow-gating by setting follow_gated: true in config.yaml. --- config.example.yaml | 2 +- lib/config/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 3bb3ce5..1c83658 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -240,7 +240,7 @@ push_notifications: batch_size: 100 follow_cache_size: 500 follow_cache_ttl: 5m - follow_gated: true + follow_gated: false queue_size: 1000 retry_attempts: 3 retry_delay: 5s diff --git a/lib/config/config.go b/lib/config/config.go index 93671f1..368583e 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -827,7 +827,7 @@ func setDefaults() { viper.SetDefault("push_notifications.service.batch_size", 100) viper.SetDefault("push_notifications.service.retry_attempts", 3) viper.SetDefault("push_notifications.service.retry_delay", "5s") - viper.SetDefault("push_notifications.service.follow_gated", true) + viper.SetDefault("push_notifications.service.follow_gated", false) viper.SetDefault("push_notifications.service.follow_cache_size", 500) viper.SetDefault("push_notifications.service.follow_cache_ttl", "5m") }