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