Files
email_agent/internal/server/hub.go
Sam Rolfe a349b42b1f Phase 7: daily summary and read-only Hub API (AUD-73)
- internal/hub: project side of the frozen ask-contract. GET /capabilities,
  read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify
  client.
- internal/apprise: daily-summary delivery through the house Apprise API.
- internal/summary: deterministic plain-text daily summary builder.
- internal/store: CountByCategory, MessagesByCategory, and summary CRUD.
- internal/server: /capabilities and /ask (bearer-token protected), plus the
  Reports screen (/reports, build, send, delete).
- main.go: -summary, -summary-send, and -notify CLI entry points for n8n.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:38:49 +11:00

99 lines
2.9 KiB
Go

// 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)
}