social.go raw

   1  //go:build !(js && wasm)
   2  
   3  package acl
   4  
   5  import (
   6  	"context"
   7  	"encoding/hex"
   8  	"math"
   9  	"reflect"
  10  	"sync"
  11  	"time"
  12  
  13  	"next.orly.dev/pkg/lol/chk"
  14  	"next.orly.dev/pkg/lol/errorf"
  15  	"next.orly.dev/pkg/lol/log"
  16  	"next.orly.dev/app/config"
  17  	"next.orly.dev/pkg/database"
  18  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  19  	"next.orly.dev/pkg/nostr/encoders/event"
  20  	nhex "next.orly.dev/pkg/nostr/encoders/hex"
  21  )
  22  
  23  // Social is an ACL driver that uses WoT graph topology with inbound-trust
  24  // rate limiting. It assigns throttle tiers based on BFS depth from anchor
  25  // pubkeys (owners/admins), and modulates outsider throttling based on
  26  // interaction signals from trusted users.
  27  type Social struct {
  28  	Ctx context.Context
  29  	cfg *config.C
  30  	db  database.Database
  31  
  32  	// Concrete database for graph traversal (type-asserted from database.Database)
  33  	badgerDB *database.D
  34  
  35  	// Identity sets
  36  	mu        sync.RWMutex
  37  	owners    [][]byte
  38  	admins    [][]byte
  39  	ownersSet map[string]struct{}
  40  	adminsSet map[string]struct{}
  41  
  42  	// WoT depth map (shared with GC)
  43  	wotMap *WoTDepthMap
  44  
  45  	// Per-depth throttles
  46  	depth2Throttle   *ProgressiveThrottle
  47  	depth3Throttle   *ProgressiveThrottle
  48  	outsiderThrottle *ProgressiveThrottle
  49  
  50  	// Inbound trust signals: outsider pubkey hex → interaction signal
  51  	trustMu      sync.Mutex
  52  	trustSignals map[string]*TrustSignal
  53  }
  54  
  55  // TrustSignal records the accumulated trust an outsider has earned from
  56  // interactions by trusted users. Count decays by halving every 24 hours.
  57  type TrustSignal struct {
  58  	Count     float64
  59  	LastDecay time.Time
  60  }
  61  
  62  func (s *Social) Configure(cfg ...any) (err error) {
  63  	log.I.F("configuring social ACL")
  64  	for _, ca := range cfg {
  65  		switch c := ca.(type) {
  66  		case *config.C:
  67  			s.cfg = c
  68  		case database.Database:
  69  			s.db = c
  70  		case context.Context:
  71  			s.Ctx = c
  72  		default:
  73  			err = errorf.E("invalid type: %T", reflect.TypeOf(ca))
  74  		}
  75  	}
  76  	if s.cfg == nil || s.db == nil {
  77  		return errorf.E("both config and database must be set")
  78  	}
  79  
  80  	// Type-assert to concrete Badger database for graph traversal
  81  	if d, ok := s.db.(*database.D); ok {
  82  		s.badgerDB = d
  83  	} else {
  84  		log.W.F("social ACL: database is not Badger, graph traversal will be unavailable")
  85  	}
  86  
  87  	// Parse owner and admin pubkeys
  88  	s.ownersSet = make(map[string]struct{})
  89  	s.adminsSet = make(map[string]struct{})
  90  	s.trustSignals = make(map[string]*TrustSignal)
  91  
  92  	for _, owner := range s.cfg.Owners {
  93  		if own, e := bech32encoding.NpubOrHexToPublicKeyBinary(owner); !chk.E(e) {
  94  			s.owners = append(s.owners, own)
  95  			s.ownersSet[hex.EncodeToString(own)] = struct{}{}
  96  		}
  97  	}
  98  	for _, admin := range s.cfg.Admins {
  99  		if adm, e := bech32encoding.NpubOrHexToPublicKeyBinary(admin); !chk.E(e) {
 100  			s.admins = append(s.admins, adm)
 101  			s.adminsSet[hex.EncodeToString(adm)] = struct{}{}
 102  		}
 103  	}
 104  
 105  	// Build anchor set for WoT (owners + admins)
 106  	anchors := make([][]byte, 0, len(s.owners)+len(s.admins))
 107  	anchors = append(anchors, s.owners...)
 108  	anchors = append(anchors, s.admins...)
 109  
 110  	// Get social config values
 111  	d2Inc, d2Max, d3Inc, d3Max, outInc, outMax, wotDepth, _ := s.cfg.GetSocialConfigValues()
 112  
 113  	// Initialize WoT depth map
 114  	s.wotMap = NewWoTDepthMap(anchors, wotDepth)
 115  
 116  	// Initialize throttles for each depth tier
 117  	s.depth2Throttle = NewProgressiveThrottle(d2Inc, d2Max)
 118  	s.depth3Throttle = NewProgressiveThrottle(d3Inc, d3Max)
 119  	s.outsiderThrottle = NewProgressiveThrottle(outInc, outMax)
 120  
 121  	log.I.F("social ACL configured: %d owners, %d admins, WoT depth %d",
 122  		len(s.owners), len(s.admins), wotDepth)
 123  	log.I.F("social ACL throttles: d2=%v/%v d3=%v/%v outsider=%v/%v",
 124  		d2Inc, d2Max, d3Inc, d3Max, outInc, outMax)
 125  
 126  	return nil
 127  }
 128  
 129  func (s *Social) GetAccessLevel(pub []byte, address string) (level string) {
 130  	pubHex := hex.EncodeToString(pub)
 131  
 132  	s.mu.RLock()
 133  	defer s.mu.RUnlock()
 134  
 135  	if _, ok := s.ownersSet[pubHex]; ok {
 136  		return "owner"
 137  	}
 138  	if _, ok := s.adminsSet[pubHex]; ok {
 139  		return "admin"
 140  	}
 141  
 142  	// All users get write access; throttling is applied separately via GetThrottleDelay
 143  	return "write"
 144  }
 145  
 146  func (s *Social) GetACLInfo() (name, description, documentation string) {
 147  	return "social",
 148  		"WoT graph topology with inbound-trust rate limiting",
 149  		`Social ACL assigns throttle tiers based on WoT depth from relay owners/admins.
 150  Depth 1 (direct follows): no throttle.
 151  Depth 2: light throttle.
 152  Depth 3: moderate throttle.
 153  Outsiders: heavy throttle, reduced by inbound trust signals from trusted users.`
 154  }
 155  
 156  func (s *Social) Type() string { return "social" }
 157  
 158  // GetThrottleDelay returns the rate-limit delay for this pubkey/IP based on
 159  // WoT depth and inbound trust signals.
 160  func (s *Social) GetThrottleDelay(pubkey []byte, ip string) time.Duration {
 161  	pubkeyHex := hex.EncodeToString(pubkey)
 162  
 163  	s.mu.RLock()
 164  	// Owners and admins are never throttled
 165  	if _, ok := s.ownersSet[pubkeyHex]; ok {
 166  		s.mu.RUnlock()
 167  		return 0
 168  	}
 169  	if _, ok := s.adminsSet[pubkeyHex]; ok {
 170  		s.mu.RUnlock()
 171  		return 0
 172  	}
 173  	s.mu.RUnlock()
 174  
 175  	depth := s.wotMap.GetDepthByHex(pubkeyHex)
 176  
 177  	switch {
 178  	case depth == 0: // anchor (owner/admin already handled above)
 179  		return 0
 180  	case depth == 1: // direct follow
 181  		return 0
 182  	case depth == 2:
 183  		return s.depth2Throttle.GetDelay(ip, pubkeyHex)
 184  	case depth == 3:
 185  		return s.depth3Throttle.GetDelay(ip, pubkeyHex)
 186  	default: // outsider (depth == -1 from GetDepthByHex)
 187  		baseDelay := s.outsiderThrottle.GetDelay(ip, pubkeyHex)
 188  		trustMult := s.getTrustMultiplier(pubkeyHex)
 189  		// trustMult of 1.0 = fully trusted by insiders = zero delay
 190  		// trustMult of 0.0 = no trust signals = full delay
 191  		adjusted := time.Duration(float64(baseDelay) * (1.0 - trustMult))
 192  		return adjusted
 193  	}
 194  }
 195  
 196  // CheckPolicy implements PolicyChecker. It inspects events from trusted users
 197  // (WoT depth 1-3) to detect interactions with outsiders and records trust signals.
 198  // It never rejects events.
 199  func (s *Social) CheckPolicy(ev *event.E) (bool, error) {
 200  	if s.wotMap == nil {
 201  		return true, nil
 202  	}
 203  
 204  	authorHex := nhex.Enc(ev.Pubkey)
 205  	authorDepth := s.wotMap.GetDepthByHex(authorHex)
 206  
 207  	// Only record trust signals from WoT members (depth 0-3)
 208  	if authorDepth < 0 {
 209  		return true, nil
 210  	}
 211  
 212  	switch ev.Kind {
 213  	case 1: // Text note — check for reply e-tags
 214  		s.recordInteractionsFromETags(ev)
 215  	case 7: // Reaction — check for e-tag target
 216  		s.recordInteractionsFromETags(ev)
 217  	case 9735: // Zap receipt — check for p-tag target
 218  		s.recordInteractionsFromPTags(ev)
 219  	case 3: // Follow list — check for p-tag targets
 220  		s.recordInteractionsFromPTags(ev)
 221  	}
 222  
 223  	return true, nil
 224  }
 225  
 226  // recordInteractionsFromETags finds e-tag targets, resolves their authors,
 227  // and records trust signals for outsiders.
 228  func (s *Social) recordInteractionsFromETags(ev *event.E) {
 229  	if s.badgerDB == nil {
 230  		return
 231  	}
 232  	eTags := ev.Tags.GetAll([]byte("e"))
 233  	for _, eTag := range eTags {
 234  		if eTag.Len() < 2 {
 235  			continue
 236  		}
 237  		targetID, err := nhex.Dec(string(eTag.ValueHex()))
 238  		if err != nil || len(targetID) != 32 {
 239  			continue
 240  		}
 241  		// Look up the target event to find its author
 242  		ser, err := s.badgerDB.GetSerialById(targetID)
 243  		if err != nil || ser == nil {
 244  			continue
 245  		}
 246  		targetEv, err := s.badgerDB.FetchEventBySerial(ser)
 247  		if err != nil || targetEv == nil {
 248  			continue
 249  		}
 250  		targetHex := nhex.Enc(targetEv.Pubkey)
 251  		// Only record if target is an outsider
 252  		if s.wotMap.GetDepthByHex(targetHex) < 0 {
 253  			s.recordInteraction(targetHex)
 254  		}
 255  	}
 256  }
 257  
 258  // recordInteractionsFromPTags records trust signals for outsiders referenced
 259  // in p-tags of the event.
 260  func (s *Social) recordInteractionsFromPTags(ev *event.E) {
 261  	pTags := ev.Tags.GetAll([]byte("p"))
 262  	for _, pTag := range pTags {
 263  		if pTag.Len() < 2 {
 264  			continue
 265  		}
 266  		targetHex := string(pTag.ValueHex())
 267  		if len(targetHex) != 64 {
 268  			continue
 269  		}
 270  		// Only record if target is an outsider
 271  		if s.wotMap.GetDepthByHex(targetHex) < 0 {
 272  			s.recordInteraction(targetHex)
 273  		}
 274  	}
 275  }
 276  
 277  // recordInteraction increments the trust signal count for an outsider pubkey.
 278  func (s *Social) recordInteraction(outsiderPubkeyHex string) {
 279  	s.trustMu.Lock()
 280  	defer s.trustMu.Unlock()
 281  
 282  	signal, ok := s.trustSignals[outsiderPubkeyHex]
 283  	if !ok {
 284  		signal = &TrustSignal{LastDecay: time.Now()}
 285  		s.trustSignals[outsiderPubkeyHex] = signal
 286  	}
 287  
 288  	// Apply pending decay before incrementing
 289  	s.applyDecay(signal)
 290  	signal.Count++
 291  }
 292  
 293  // getTrustMultiplier returns a value in [0.0, 1.0) where higher = more trusted.
 294  // Uses the formula: 1.0 - (1.0 / (1.0 + count/5.0))
 295  // This gives: 0 interactions → 0.0, 5 → 0.5, 10 → 0.67, 20 → 0.8
 296  func (s *Social) getTrustMultiplier(pubkeyHex string) float64 {
 297  	s.trustMu.Lock()
 298  	defer s.trustMu.Unlock()
 299  
 300  	signal, ok := s.trustSignals[pubkeyHex]
 301  	if !ok {
 302  		return 0.0
 303  	}
 304  
 305  	s.applyDecay(signal)
 306  
 307  	if signal.Count < 0.01 {
 308  		return 0.0
 309  	}
 310  
 311  	return 1.0 - (1.0 / (1.0 + signal.Count/5.0))
 312  }
 313  
 314  // applyDecay halves the trust signal count for each 24-hour period elapsed.
 315  // Must be called with trustMu held.
 316  func (s *Social) applyDecay(signal *TrustSignal) {
 317  	hoursSince := time.Since(signal.LastDecay).Hours()
 318  	if hoursSince >= 24 {
 319  		halvings := int(hoursSince / 24)
 320  		signal.Count *= math.Pow(0.5, float64(halvings))
 321  		signal.LastDecay = time.Now()
 322  	}
 323  }
 324  
 325  // GetWoTDepthMap returns the shared WoT depth map for GC integration.
 326  func (s *Social) GetWoTDepthMap() *WoTDepthMap {
 327  	return s.wotMap
 328  }
 329  
 330  // Syncer starts background goroutines for WoT recomputation, throttle cleanup,
 331  // and trust signal maintenance.
 332  func (s *Social) Syncer() {
 333  	log.D.F("starting social syncer")
 334  
 335  	// Compute WoT map on startup
 336  	if s.badgerDB != nil {
 337  		go s.wotRefreshLoop()
 338  	}
 339  
 340  	// Throttle cleanup for all three tiers
 341  	go s.throttleCleanupLoop()
 342  
 343  	// Trust signal decay and cleanup
 344  	go s.trustCleanupLoop()
 345  }
 346  
 347  func (s *Social) wotRefreshLoop() {
 348  	// Initial computation
 349  	if err := s.wotMap.Recompute(s.badgerDB); err != nil {
 350  		log.W.F("social ACL: initial WoT recompute failed: %v", err)
 351  	}
 352  
 353  	_, _, _, _, _, _, _, refreshInterval := s.cfg.GetSocialConfigValues()
 354  	if refreshInterval <= 0 {
 355  		refreshInterval = time.Hour
 356  	}
 357  
 358  	ticker := time.NewTicker(refreshInterval)
 359  	defer ticker.Stop()
 360  
 361  	for {
 362  		select {
 363  		case <-s.Ctx.Done():
 364  			return
 365  		case <-ticker.C:
 366  			if err := s.wotMap.Recompute(s.badgerDB); err != nil {
 367  				log.W.F("social ACL: WoT recompute failed: %v", err)
 368  			}
 369  		}
 370  	}
 371  }
 372  
 373  func (s *Social) throttleCleanupLoop() {
 374  	ticker := time.NewTicker(10 * time.Minute)
 375  	defer ticker.Stop()
 376  
 377  	for {
 378  		select {
 379  		case <-s.Ctx.Done():
 380  			return
 381  		case <-ticker.C:
 382  			s.depth2Throttle.Cleanup()
 383  			s.depth3Throttle.Cleanup()
 384  			s.outsiderThrottle.Cleanup()
 385  		}
 386  	}
 387  }
 388  
 389  func (s *Social) trustCleanupLoop() {
 390  	ticker := time.NewTicker(24 * time.Hour)
 391  	defer ticker.Stop()
 392  
 393  	for {
 394  		select {
 395  		case <-s.Ctx.Done():
 396  			return
 397  		case <-ticker.C:
 398  			s.decayAndCleanTrustSignals()
 399  		}
 400  	}
 401  }
 402  
 403  // decayAndCleanTrustSignals applies decay to all signals and removes dead ones.
 404  func (s *Social) decayAndCleanTrustSignals() {
 405  	s.trustMu.Lock()
 406  	defer s.trustMu.Unlock()
 407  
 408  	for key, signal := range s.trustSignals {
 409  		s.applyDecay(signal)
 410  		if signal.Count < 0.01 {
 411  			delete(s.trustSignals, key)
 412  		}
 413  	}
 414  	log.T.F("social ACL: trust signals cleanup, %d active", len(s.trustSignals))
 415  }
 416