// The Hub contract surface (AGENTS.md §4 FR-11, §8, §10 rule 5, §11 Phase 8). // // The project side of central_ai_llm_master/docs/ask-contract.md: // // GET /capabilities -> the topics this service answers (Hub routing) // POST /ask -> one short, read-only answer about school work // POST /notify -> accept the contract's notification and deliver it // // All three require the shared Hub bearer token (SCHOOL_HUB_TOKEN). A // service-to-service call must never be trusted unsigned. /ask and // /capabilities are read-only: they call only store read methods. /notify is // the one write on this surface, and it only records and delivers a message; // it never changes school data and never touches the school (AGENTS.md §10 // rule 1). package web import ( "context" "crypto/subtle" "encoding/json" "fmt" "net/http" "strings" "time" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/hub" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/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 and // matches the sibling project. type notifyResponse struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` } // hubAuthOK enforces the shared bearer token on the Hub endpoints. It writes // the refusal and returns false when the request may not be served. askShape // selects the contract-shaped body for /ask; the other endpoints get the // minimal error body. func (s *Server) hubAuthOK(w http.ResponseWriter, r *http.Request, askShape bool) bool { refuse := func(code int, reason string) { if askShape { s.writeJSON(w, code, hub.AskResponse{Answer: nil, Reason: reason}) return } s.writeJSON(w, code, notifyResponse{Error: reason}) } if !s.cfg.HubConfigured() { s.log.Warn("hub api disabled: SCHOOL_HUB_TOKEN is not set", "path", r.URL.Path) refuse(http.StatusServiceUnavailable, "the Hub API is not configured") return false } provided := bearerTokenOf(r) if subtle.ConstantTimeCompare([]byte(provided), []byte(strings.TrimSpace(s.cfg.HubToken))) != 1 { s.log.Warn("hub api rejected", "path", r.URL.Path, "remote", r.RemoteAddr) w.Header().Set("WWW-Authenticate", `Bearer realm="compass-nhs"`) refuse(http.StatusUnauthorized, "unauthorized") return false } return true } // handleCapabilities returns the routing topics for the Hub. It holds no // private data. func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) { if !s.hubAuthOK(w, r, false) { return } s.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) { if !s.hubAuthOK(w, r, true) { return } // The contract caps the answer at 3 seconds; the timeout leaves room for the // write rather than cutting off mid-reply. 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 { s.writeJSON(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, "question", req.Question, "answered", resp.Answer != nil, "reason", resp.Reason, "duration_ms", time.Since(started).Milliseconds(), ) s.writeJSON(w, http.StatusOK, resp) } // handleNotify accepts a notification in the frozen contract's shape and // delivers it to the named person through Apprise. The message is recorded // before delivery, so a delivery failure is visible on the dashboard and in // `notifications` (FR-12). func (s *Server) handleNotify(w http.ResponseWriter, r *http.Request) { if !s.hubAuthOK(w, r, false) { return } 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 { s.writeJSON(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: s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: "level must be info, important or urgent"}) return } message := strings.TrimSpace(req.Message) if message == "" { s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: "message is required"}) return } if len(message) > 500 { message = message[:500] } recipient, err := s.notifyRecipient(req.Person) if err != nil { s.writeJSON(w, http.StatusBadRequest, notifyResponse{Error: err.Error()}) return } if !s.cfg.Apprise.Enabled() { s.writeJSON(w, http.StatusServiceUnavailable, notifyResponse{ Error: "apprise is not configured: set " + strings.Join(s.cfg.Apprise.Missing(), ", "), }) return } if s.apprise == nil || !s.apprise.Configured() { s.writeJSON(w, http.StatusServiceUnavailable, notifyResponse{Error: "apprise is not configured"}) return } title := "Compass / NHS — " + from channels := s.cfg.Apprise.Channels if len(channels) == 0 { channels = []string{config.ChannelEmail} } now := time.Now() failed := 0 for _, ch := range channels { tag := s.cfg.Apprise.ChannelTag(recipient, ch) res, sendErr := s.apprise.SendTags(ctx, []string{tag}, title, message, notifyType(level)) n := store.Notification{ Scope: "hub", Recipient: recipient.Name, Channel: ch, Tag: tag, Text: message, OK: sendErr == nil, } if sendErr != nil { n.Error = apprise.Truncate(sendErr.Error(), 300) failed++ } else { sent := now n.SentAt = &sent } if _, recErr := s.store.RecordNotification(ctx, n); recErr != nil { s.log.Error("hub notify record", "recipient", recipient.Name, "err", recErr) } if sendErr != nil { s.log.Error("hub notify NOT delivered", "recipient", recipient.Name, "channel", ch, "tag", tag, "status", res.Status, "err", n.Error) } else { s.log.Info("hub notify delivered", "from", from, "level", level, "recipient", recipient.Name, "channel", ch, "tag", tag, "status", res.Status) } } if failed > 0 { s.writeJSON(w, http.StatusBadGateway, notifyResponse{Error: fmt.Sprintf("delivery failed on %d channel(s)", failed)}) return } s.writeJSON(w, http.StatusOK, notifyResponse{OK: true}) } // notifyRecipient resolves the contract's `person` to a configured Apprise // recipient. An empty person defaults to Sam, the service's owner; an unknown // person is refused rather than delivered to the wrong inbox. func (s *Server) notifyRecipient(person string) (config.Recipient, error) { person = strings.TrimSpace(person) if person == "" { if r, ok := s.cfg.Apprise.RecipientByName("Sam"); ok { return r, nil } if parents := s.cfg.Apprise.ParentRecipients(); len(parents) > 0 { return parents[0], nil } return config.Recipient{}, fmt.Errorf("no recipient is configured") } r, ok := s.cfg.Apprise.RecipientByName(person) if !ok { return config.Recipient{}, fmt.Errorf("unknown person %q", person) } return r, nil } // 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 } } // bearerTokenOf reads the bearer token from the Authorization header, // case-insensitively. func bearerTokenOf(r *http.Request) string { h := strings.TrimSpace(r.Header.Get("Authorization")) if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") { return "" } return strings.TrimSpace(h[7:]) }