// The Hub contract surface (AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9). // // The project side of central_ai_llm_master/docs/ask-contract.md: // // GET /capabilities -> the topics this service answers (Hub routing) // POST /ask -> a short answer about screen time and devices // POST /notify -> accept a notification and deliver it through Apprise // // All three require the shared Hub bearer token. A service-to-service call must // never be trusted unsigned. /ask and /capabilities are read-only: they call // only store read methods (AGENTS.md §10 rule 9). /notify is the one write on // this surface, and it only records and delivers a message; it never changes // device state. package web import ( "context" "crypto/subtle" "encoding/json" "fmt" "net/http" "strings" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // hubMaxBody bounds a Hub request body. Questions and notifications are small. const hubMaxBody = 64 << 10 // 64 KiB // notifyResponse is this service's own reply to POST /notify. The contract does // not freeze a response shape for the receiver, so this is kept minimal. type notifyResponse struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` } // hubAuthRequired writes the contract-shaped refusal when the Hub API has no // token. 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() { writeHubJSON(w, http.StatusServiceUnavailable, hub.AskResponse{ Answer: nil, Reason: "the Hub API is not configured", }) return false } return true } // requireHub enforces the shared 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 } token, ok := bearerToken(r) if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.HubToken)) != 1 { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) _ = s.store.Audit(ctx, "anonymous", "hub.token_rejected", nil, r.URL.Path+" from "+clientIP(r)) cancel() s.log.Warn("hub api rejected", "path", r.URL.Path, "remote", r.RemoteAddr) w.Header().Set("WWW-Authenticate", `Bearer realm="internet-use-control"`) writeHubJSON(w, http.StatusUnauthorized, hub.AskResponse{ Answer: nil, Reason: "unauthorized", }) return } next(w, r) } } // handleCapabilities returns the routing topics for the Hub. It holds no // private data. func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { writeHubJSON(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, hubMaxBody)) if err := dec.Decode(&req); err != nil { writeHubJSON(w, http.StatusOK, hub.AskResponse{ Answer: nil, Reason: "the request body is not valid JSON", }) return } started := time.Now() resp := s.answerer.Answer(ctx, req) s.log.Info("hub ask", "asker", req.Asker, "person", req.Person, "source", req.Source, "answered", resp.Answer != nil, "duration_ms", time.Since(started).Milliseconds(), ) writeHubJSON(w, http.StatusOK, resp) } // handleNotify accepts a notification in the frozen contract's shape and // delivers it to Sam through Apprise. It is how a tamper report from a phone // client (FR-17) will reach Sam without inventing a second shape. The message // is recorded before delivery so a delivery failure is visible. func (s *Server) handleNotify(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() var req hub.NotifyRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody)) if err := dec.Decode(&req); err != nil { writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "invalid JSON body"}) return } from := strings.TrimSpace(req.From) if from == "" { from = hub.ProjectName } level := strings.ToLower(strings.TrimSpace(req.Level)) switch level { case "": level = "info" case "info", "important", "urgent": default: writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "level must be info, important or urgent"}) return } message := strings.TrimSpace(req.Message) if message == "" { writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "message is required"}) return } if len(message) > 500 { message = message[:500] } detail := fmt.Sprintf("[%s] %s — from %s", level, message, from) record, err := s.store.CreateAlert(ctx, nil, store.AlertNotify, detail) if err != nil { s.log.Error("notify record", "from", from, "err", err) writeHubJSON(w, http.StatusInternalServerError, notifyResponse{Error: "could not record the notification"}) return } if !s.apprise.Configured() { _ = s.store.MarkAlertDelivered(ctx, record.ID, "apprise is not configured") writeHubJSON(w, http.StatusServiceUnavailable, notifyResponse{Error: "apprise is not configured"}) return } if err := s.apprise.SendType(ctx, "Internet Use Control — "+from, message, notifyType(level)); err != nil { _ = s.store.MarkAlertDelivered(ctx, record.ID, err.Error()) s.log.Error("notify delivery", "from", from, "err", err) writeHubJSON(w, http.StatusBadGateway, notifyResponse{Error: "delivery failed"}) return } if err := s.store.MarkAlertDelivered(ctx, record.ID, ""); err != nil { s.log.Error("notify record", "id", record.ID, "err", err) } s.log.Info("notify delivered", "from", from, "level", level, "person", req.Person, "alert_id", record.ID) writeHubJSON(w, http.StatusOK, notifyResponse{OK: true}) } // notifyType maps the contract's level to an Apprise notification class. func notifyType(level string) string { switch level { case "important": return apprise.TypeWarning case "urgent": return apprise.TypeFailure default: return apprise.TypeInfo } } // writeHubJSON writes one JSON value with the given status. func writeHubJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) }