// Hub API for Phase 7 (FR-12). // // These are the project side of the frozen contract in // central_ai_llm_master/docs/ask-contract.md: // // GET /capabilities -> the topics this project answers (Hub routing) // POST /ask -> answer a question about this project's data // // Both endpoints require the shared bearer token. /ask is read-only: it calls // only store read methods and writes nothing, including no actions_log row. // This is a deliberate, testable property (see TestAskWritesNothing). package server import ( "context" "crypto/subtle" "encoding/json" "net/http" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/hub" ) // hubAuthRequired reports whether the Hub API has a token configured. With no // token the endpoints refuse to serve rather than trust an unsigned request. func (s *Server) hubAuthRequired(w http.ResponseWriter) bool { if !s.cfg.HubConfigured() { writeJSON(w, http.StatusServiceUnavailable, map[string]any{ "answer": nil, "reason": "the Hub API is not configured", }) return false } return true } // requireHub enforces the project bearer token on the Hub endpoints. func (s *Server) requireHub(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.hubAuthRequired(w) { return } if subtle.ConstantTimeCompare([]byte(bearerToken(r)), []byte(s.cfg.HubToken)) != 1 { writeJSON(w, http.StatusUnauthorized, map[string]any{ "answer": nil, "reason": "unauthorized", }) return } next(w, r) } } // bearerToken reads the project token from the Authorization header. func bearerToken(r *http.Request) string { h := strings.TrimSpace(r.Header.Get("Authorization")) if h == "" { return "" } if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") { return strings.TrimSpace(h[7:]) } return h } // handleCapabilities returns the routing topics for the Hub. It holds no // private data. func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, hub.DefaultCapabilities()) } // handleAsk answers one question from this project's read-only data. The reply // is always HTTP 200 with either an answer or a reason, so the Hub can relay a // "cannot answer" without treating the project as down. func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() var req hub.AskRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusOK, hub.AskResponse{ Answer: nil, Reason: "the request body is not valid JSON", }) return } resp := s.answerer.Answer(ctx, req) s.logger.Debug("hub ask", "asker", req.Asker, "person", req.Person, "source", req.Source, "answered", resp.Answer != nil, ) writeJSON(w, http.StatusOK, resp) }