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