Files
compass_teams_nhs/internal/web/hub_api.go
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:47:32 +11:00

258 lines
8.3 KiB
Go

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