handle-grapevine.go raw

   1  package app
   2  
   3  import (
   4  	"encoding/json"
   5  	"net/http"
   6  	"strings"
   7  
   8  	"next.orly.dev/pkg/acl"
   9  	"next.orly.dev/pkg/nostr/encoders/hex"
  10  	"next.orly.dev/pkg/nostr/httpauth"
  11  	"next.orly.dev/pkg/lol/chk"
  12  )
  13  
  14  // handleGrapeVineScores handles GET /api/grapevine/scores?observer=<hex>
  15  // Returns the full score set for an observer.
  16  func (s *Server) handleGrapeVineScores(w http.ResponseWriter, r *http.Request) {
  17  	if r.Method != http.MethodGet {
  18  		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  19  		return
  20  	}
  21  
  22  	if s.grapeVineEngine == nil {
  23  		http.Error(w, "GrapeVine API not enabled", http.StatusServiceUnavailable)
  24  		return
  25  	}
  26  
  27  	// NIP-98 auth
  28  	valid, pubkey, err := httpauth.CheckAuth(r)
  29  	if chk.E(err) || !valid {
  30  		errorMsg := "NIP-98 authentication failed"
  31  		if err != nil {
  32  			errorMsg = err.Error()
  33  		}
  34  		http.Error(w, errorMsg, http.StatusUnauthorized)
  35  		return
  36  	}
  37  
  38  	authedHex := hex.Enc(pubkey)
  39  	observerHex := r.URL.Query().Get("observer")
  40  	if observerHex == "" {
  41  		observerHex = authedHex
  42  	}
  43  
  44  	// Non-owner can only query their own scores
  45  	if observerHex != authedHex {
  46  		accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
  47  		if accessLevel != "owner" && accessLevel != "admin" {
  48  			http.Error(w, "Can only query your own scores", http.StatusForbidden)
  49  			return
  50  		}
  51  	}
  52  
  53  	set, err := s.grapeVineEngine.GetScores(observerHex)
  54  	if err != nil {
  55  		http.Error(w, "Failed to retrieve scores", http.StatusInternalServerError)
  56  		return
  57  	}
  58  	if set == nil {
  59  		http.Error(w, "No scores computed for this observer", http.StatusNotFound)
  60  		return
  61  	}
  62  
  63  	w.Header().Set("Content-Type", "application/json")
  64  	json.NewEncoder(w).Encode(set)
  65  }
  66  
  67  // handleGrapeVineScore handles GET /api/grapevine/score?observer=<hex>&target=<hex>
  68  // Returns a single score entry.
  69  func (s *Server) handleGrapeVineScore(w http.ResponseWriter, r *http.Request) {
  70  	if r.Method != http.MethodGet {
  71  		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  72  		return
  73  	}
  74  
  75  	if s.grapeVineEngine == nil {
  76  		http.Error(w, "GrapeVine API not enabled", http.StatusServiceUnavailable)
  77  		return
  78  	}
  79  
  80  	// NIP-98 auth
  81  	valid, pubkey, err := httpauth.CheckAuth(r)
  82  	if chk.E(err) || !valid {
  83  		errorMsg := "NIP-98 authentication failed"
  84  		if err != nil {
  85  			errorMsg = err.Error()
  86  		}
  87  		http.Error(w, errorMsg, http.StatusUnauthorized)
  88  		return
  89  	}
  90  
  91  	authedHex := hex.Enc(pubkey)
  92  	observerHex := r.URL.Query().Get("observer")
  93  	if observerHex == "" {
  94  		observerHex = authedHex
  95  	}
  96  	targetHex := r.URL.Query().Get("target")
  97  	if targetHex == "" {
  98  		http.Error(w, "Missing required 'target' parameter", http.StatusBadRequest)
  99  		return
 100  	}
 101  
 102  	// Non-owner can only query their own scores
 103  	if observerHex != authedHex {
 104  		accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
 105  		if accessLevel != "owner" && accessLevel != "admin" {
 106  			http.Error(w, "Can only query your own scores", http.StatusForbidden)
 107  			return
 108  		}
 109  	}
 110  
 111  	entry, err := s.grapeVineEngine.GetScore(observerHex, targetHex)
 112  	if err != nil {
 113  		http.Error(w, "Failed to retrieve score", http.StatusInternalServerError)
 114  		return
 115  	}
 116  	if entry == nil {
 117  		http.Error(w, "Score not found", http.StatusNotFound)
 118  		return
 119  	}
 120  
 121  	resp := struct {
 122  		Observer  string  `json:"observer"`
 123  		Target    string  `json:"target"`
 124  		Influence float64 `json:"influence"`
 125  		Average   float64 `json:"average"`
 126  		Certainty float64 `json:"certainty"`
 127  		Input     float64 `json:"input"`
 128  		WoTScore  int     `json:"wot_score"`
 129  		Depth     int     `json:"depth"`
 130  	}{
 131  		Observer:  observerHex,
 132  		Target:    targetHex,
 133  		Influence: entry.Influence,
 134  		Average:   entry.Average,
 135  		Certainty: entry.Certainty,
 136  		Input:     entry.Input,
 137  		WoTScore:  entry.WoTScore,
 138  		Depth:     entry.Depth,
 139  	}
 140  
 141  	w.Header().Set("Content-Type", "application/json")
 142  	json.NewEncoder(w).Encode(resp)
 143  }
 144  
 145  // handleGrapeVineRecalculate handles POST /api/grapevine/recalculate
 146  // Body: {"observer":"<hex>"} — triggers async recomputation.
 147  func (s *Server) handleGrapeVineRecalculate(w http.ResponseWriter, r *http.Request) {
 148  	if r.Method != http.MethodPost {
 149  		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 150  		return
 151  	}
 152  
 153  	if s.grapeVineEngine == nil || s.grapeVineScheduler == nil {
 154  		http.Error(w, "GrapeVine API not enabled", http.StatusServiceUnavailable)
 155  		return
 156  	}
 157  
 158  	// NIP-98 auth
 159  	valid, pubkey, err := httpauth.CheckAuth(r)
 160  	if chk.E(err) || !valid {
 161  		errorMsg := "NIP-98 authentication failed"
 162  		if err != nil {
 163  			errorMsg = err.Error()
 164  		}
 165  		http.Error(w, errorMsg, http.StatusUnauthorized)
 166  		return
 167  	}
 168  
 169  	authedHex := hex.Enc(pubkey)
 170  
 171  	var req struct {
 172  		Observer string `json:"observer"`
 173  	}
 174  	if r.Body != nil {
 175  		json.NewDecoder(r.Body).Decode(&req)
 176  	}
 177  	if req.Observer == "" {
 178  		req.Observer = authedHex
 179  	}
 180  
 181  	// Non-owner can only trigger their own recalculation
 182  	if req.Observer != authedHex {
 183  		accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
 184  		if accessLevel != "owner" && accessLevel != "admin" {
 185  			http.Error(w, "Can only recalculate your own scores", http.StatusForbidden)
 186  			return
 187  		}
 188  	}
 189  
 190  	// Validate hex pubkey format
 191  	req.Observer = strings.ToLower(req.Observer)
 192  	if len(req.Observer) != 64 {
 193  		http.Error(w, "Invalid observer pubkey (must be 64-char hex)", http.StatusBadRequest)
 194  		return
 195  	}
 196  
 197  	started := s.grapeVineScheduler.TriggerCompute(req.Observer)
 198  
 199  	status := "started"
 200  	if !started {
 201  		status = "already_computing"
 202  	}
 203  
 204  	w.Header().Set("Content-Type", "application/json")
 205  	w.WriteHeader(http.StatusAccepted)
 206  	json.NewEncoder(w).Encode(map[string]string{
 207  		"status":   status,
 208  		"observer": req.Observer,
 209  	})
 210  }
 211