1package daemon23// Notification delivery is deliberately separate from ordinary replication.4// A relay-generated gift wrap is private data: it is stored locally for audit,5// broadcast to current local subscribers through a fenced work item, and sent6// only to the recipient's NIP-17 inbox relay list (kind 10050).78import (9 "context"10 "encoding/json"11 "errors"12 "fmt"13 "strings"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/replication"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19 "github.com/FelineStateMachine/tinyrelay/internal/work"20)2122const (23 notificationBroadcast = "notification-broadcast"24 notificationDelivery = "notification-delivery"25 notificationPush = "notification-push"26)2728type notificationPayload struct {29 Recipient string `json:"recipient"`30}3132// deliverNotification is suitable for records.Config.DeliverNotification.33// Save persists the wrap and both fenced intents in one transaction. It does34// not synchronously fan out while the relay publish fence is held.35func (t *Tenant) deliverNotification(ctx context.Context, wrap event.Event, recipient string) error {36 if wrap.Kind != event.KIND_WRAP || recipient == "" || event.Tag(wrap, "p") != recipient {37 return errors.New("notification: invalid recipient or gift wrap")38 }39 payload, err := json.Marshal(notificationPayload{Recipient: recipient})40 if err != nil {41 return err42 }43 now := time.Now().Unix()44 _, err = t.store.Save(ctx, wrap, storage.SaveOptions{45 Now: now,46 SearchMode: "off",47 Intents: []storage.Intent{48 {Kind: notificationBroadcast, EventID: wrap.ID, Target: t.meta.ID, Payload: string(payload)},49 {Kind: notificationDelivery, EventID: wrap.ID, Target: recipient, Payload: string(payload)},50 },51 })52 return err53}5455// notificationHandlers returns handlers to merge into the tenant worker's56// ordinary handler map. The broadcast handler is local-only; the delivery57// handler uses the recipient's kind-10050 relay list and never ordinary NIP-6558// read/write discovery or the tenant's replication outbox.59func (t *Tenant) notificationHandlers() map[string]work.Handler {60 transport := &replication.NostrTransport{Dialer: replication.WebsocketDialer{AllowPrivate: t.app != nil && t.app.cfg.AllowPrivateRelays, MaxMessageBytes: func() int64 {61 if t.app != nil {62 return t.app.cfg.MaxMessageBytes63 }64 return 065 }()}}66 return map[string]work.Handler{67 notificationBroadcast: t.handleNotificationBroadcast,68 notificationDelivery: func(ctx context.Context, intent work.Intent) error {69 return t.handleNotificationDelivery(ctx, intent, transport)70 },71 }72}7374func (t *Tenant) loadNotification(ctx context.Context, id string) (event.Event, error) {75 result, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})76 if err != nil {77 return event.Event{}, err78 }79 if len(result.Events) == 0 {80 return event.Event{}, nil81 }82 return result.Events[0], nil83}8485func (t *Tenant) handleNotificationBroadcast(ctx context.Context, intent work.Intent) error {86 e, err := t.loadNotification(ctx, intent.EventID)87 if err != nil || e.ID == "" {88 return err89 }90 if e.Kind != event.KIND_WRAP || event.Tag(e, "p") == "" {91 return errors.New("notification: stored event is not a recipient gift wrap")92 }93 // BroadcastGenerated is already outside Router.Publish's write fence.94 t.router.BroadcastGenerated(e)95 return nil96}9798func (t *Tenant) handleNotificationDelivery(ctx context.Context, intent work.Intent, transport *replication.NostrTransport) error {99 var payload notificationPayload100 if intent.Payload != "" {101 if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {102 return fmt.Errorf("notification: payload: %w", err)103 }104 }105 recipient := intent.Target106 if payload.Recipient != "" && payload.Recipient != recipient {107 return errors.New("notification: recipient target mismatch")108 }109 e, err := t.loadNotification(ctx, intent.EventID)110 if err != nil {111 return err112 }113 if e.ID == "" {114 return nil115 }116 if e.Kind != event.KIND_WRAP || event.Tag(e, "p") != recipient {117 return errors.New("notification: recipient check failed")118 }119 targets, lookupErr := t.notificationInboxRelays(ctx, recipient)120 if lookupErr != nil {121 return lookupErr122 }123 if len(targets) == 0 {124 return errors.New("notification: recipient has no kind-10050 inbox relays")125 }126 for _, target := range targets {127 if sameRelay(target, t.RelayURL()) {128 continue129 }130 result, sendErr := transport.Send(ctx, target, e)131 if sendErr != nil {132 return sendErr133 }134 if result.Accepted || strings.HasPrefix(result.Message, "duplicate:") {135 continue136 }137 if result.Message == "" {138 result.Message = "recipient relay rejected notification"139 }140 return errors.New(result.Message)141 }142 return nil143}144145func (t *Tenant) notificationInboxRelays(ctx context.Context, recipient string) ([]string, error) {146 result, err := t.store.Query(ctx, event.Filter{Kinds: []int{10050}, Authors: []string{recipient}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})147 if err != nil {148 return nil, err149 }150 if len(result.Events) == 0 && t.app != nil {151 dialer := replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}152 transport := &replication.NostrTransport{Dialer: dialer}153 var newest event.Event154 var queryErr error155 limit := 20156 for _, seed := range t.app.cfg.DiscoveryRelays {157 if !validRelayURL(seed) || sameRelay(seed, t.RelayURL()) {158 continue159 }160 events, qerr := transport.Query(ctx, seed, event.Filter{Kinds: []int{10050}, Authors: []string{recipient}, Tags: map[string][]string{}, Limit: &limit})161 if qerr != nil {162 queryErr = qerr163 continue164 }165 for _, candidate := range events {166 if candidate.Kind == 10050 && candidate.PubKey == recipient && (newest.ID == "" || candidate.CreatedAt > newest.CreatedAt) {167 newest = candidate168 }169 }170 }171 if newest.ID != "" {172 if _, saveErr := t.store.Save(ctx, newest, storage.SaveOptions{Now: time.Now().Unix(), SearchMode: "off"}); saveErr != nil && !errors.Is(saveErr, storage.ErrDuplicate) {173 return nil, saveErr174 }175 result.Events = []event.Event{newest}176 } else if queryErr != nil {177 return nil, fmt.Errorf("notification: inbox discovery: %w", queryErr)178 }179 }180 if len(result.Events) == 0 {181 return nil, nil182 }183 seen := make(map[string]bool)184 var out []string185 for _, tag := range result.Events[0].Tags {186 if len(tag) < 2 || tag[0] != "relay" || !validRelayURL(tag[1]) || seen[tag[1]] {187 continue188 }189 seen[tag[1]] = true190 out = append(out, tag[1])191 }192 return out, nil193}194195func validRelayURL(raw string) bool {196 return strings.HasPrefix(raw, "ws://") || strings.HasPrefix(raw, "wss://")197}198199func sameRelay(a, b string) bool { return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") }