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>
This commit is contained in:
2026-10-15 19:47:32 +11:00
parent 09639c5125
commit 9cbfa7db9b
13 changed files with 1720 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
IMAGE ?= compass-teams-nhs/school:0.5.0-phase7 IMAGE ?= compass-teams-nhs/school:0.6.0-phase8
BUILD_VERSION ?= 0.5.0-phase7 BUILD_VERSION ?= 0.6.0-phase8
# Parse options (see scripts/parse-compass.sh). # Parse options (see scripts/parse-compass.sh).
CHILD ?= Harry CHILD ?= Harry
@@ -19,7 +19,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)"
-e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \
-v "$$PWD":/src -w /src $(GO_IMAGE) -v "$$PWD":/src -w /src $(GO_IMAGE)
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar notify morning-brief .PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief
help: help:
@echo "make test run go test ./... in golang:1.25-alpine" @echo "make test run go test ./... in golang:1.25-alpine"
@@ -39,6 +39,7 @@ help:
@echo "make preview print the briefs without sending (Phase 7)" @echo "make preview print the briefs without sending (Phase 7)"
@echo "make morning-brief scan both children, then send the briefs (the n8n job)" @echo "make morning-brief scan both children, then send the briefs (the n8n job)"
@echo "make verify-phase1 prove /health, the login and the Caddy route" @echo "make verify-phase1 prove /health, the login and the Caddy route"
@echo "make verify-phase8 prove the Hub API: /capabilities, /ask, /notify (--send delivers)"
test: test:
$(GO_RUN) go test ./... $(GO_RUN) go test ./...
@@ -105,3 +106,8 @@ morning-brief:
# Caddy route on .35. # Caddy route on .35.
verify-phase1: verify-phase1:
deploy/verify-phase1.sh deploy/verify-phase1.sh
# Phase 8 definition of done: the Hub API answers and authenticates. Pass
# ARGS=--send to deliver one real /notify message to Sam.
verify-phase8:
deploy/verify-phase8.sh $(ARGS)

View File

@@ -4,8 +4,36 @@ A school service for Harry and Finn: **Compass** for the daily schedule and
notices, **Teams** for homework and assignments. It stores what is due per child, notices, **Teams** for homework and assignments. It stores what is due per child,
puts overdue work first, and tells the family before work is late. puts overdue work first, and tells the family before work is late.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 7** (notify > Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 8** (the
> and send via Apprise). See §11 of the spec for the phase order. > Hub API). See §11 of the spec for the phase order.
## Status — Phase 8
Phase 8 exposes the project side of the frozen Hub contract
(`central_ai_llm_master/docs/ask-contract.md`) on this service's own port
(FR-11):
| Method | Path | Notes |
|---|---|---|
| `GET` | `/capabilities` | `{"project":"compass_nhs","topics":[…],"people":[…]}`, matching the Hub's `routing.md` row. |
| `POST` | `/ask` | **Read-only.** Answers from the stored Compass items in one short sentence; returns the contract's `answer: null` + `reason` when it cannot answer. A failed or stale scan is stated in the answer, never as "nothing due" (FR-12). |
| `POST` | `/notify` | Accepts the contract's `{"from","level","message","person"}` and delivers it through Apprise. |
All three require `Authorization: Bearer $SCHOOL_HUB_TOKEN`. With no token set
they answer `503`. See [`docs/phase-8-hub-notes.md`](docs/phase-8-hub-notes.md).
```bash
make verify-phase8 # /health ok, /capabilities, /ask, /notify auth
deploy/verify-phase8.sh --send # also delivers one real /notify message to Sam
# The Hub's own client shape, against the live service:
curl -sS -H "Authorization: Bearer $SCHOOL_HUB_TOKEN" -H 'Content-Type: application/json' \
-d '{"question":"what does Harry have on today at school?","asker":"sam","person":"harry"}' \
http://127.0.0.1:8792/ask
```
The `/ask` answers are deterministic (no model on the path), so the Hub's 3
second cap is never the reason an answer is missing.
## Status — Phase 7 ## Status — Phase 7
@@ -98,8 +126,8 @@ Built and deployed on `.13`:
- Its own container and port **8792**, behind Caddy at - Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`. `school.lab.audasmedia.com.au`.
Not built yet (later phases): the Hub API. The service itself does **not** scan Not built yet (later phases): Teams (FR-15) and the Phase 9 documentation set.
the school; n8n triggers the scan. Teams (FR-15) is a separate source. The service itself does **not** scan the school; n8n triggers the scan.
### Fail loudly (FR-12) ### Fail loudly (FR-12)
@@ -120,6 +148,7 @@ internal/parse/ HTML -> structured items (tasks, messages, events)
internal/llm/ OmniRoute client (importance classification only) internal/llm/ OmniRoute client (importance classification only)
internal/apprise/ Apprise API client (tag-based, redacts errors) internal/apprise/ Apprise API client (tag-based, redacts errors)
internal/brief/ build + deliver the morning briefs (FR-9, FR-14) internal/brief/ build + deliver the morning briefs (FR-9, FR-14)
internal/hub/ the Hub contract: capabilities + read-only answers (FR-11)
internal/calendar/ Home Assistant calendar client + the idempotent sync internal/calendar/ Home Assistant calendar client + the idempotent sync
internal/auth/ session login (signed cookies, CSRF) internal/auth/ session login (signed cookies, CSRF)
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
@@ -139,8 +168,8 @@ docker compose up -d --build
Generate secrets with the built image: Generate secrets with the built image:
```bash ```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 gen-secret docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.0-phase8 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 hash-password 'the-password' docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.0-phase8 hash-password 'the-password'
``` ```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash
@@ -149,7 +178,8 @@ contains `$`, so escape each one as `$$` in the env file.
## Verify ## Verify
```bash ```bash
make verify-phase1 make verify-phase1 # container, /health, login, the Caddy route
make verify-phase8 # the Hub API: /capabilities, /ask, /notify
``` ```
It checks the container is healthy, `GET /health` returns 200, the login page It checks the container is healthy, `GET /health` returns 200, the login page

120
deploy/verify-phase8.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Phase 8 definition of done (AUD-112).
#
# - GET /capabilities returns the routing topics for `compass_nhs`
# - POST /ask answers "what does Harry have on today at school?" in one sentence
# - POST /notify is authenticated and refuses a bad request without delivering
# - an unsigned request is refused (401), and an unset token closes the API (503)
#
# Run on .13 from the project folder. It reads SCHOOL_HUB_TOKEN from
# environment.d/school-service.env (gitignored) and never prints it. /notify is
# not made to deliver a real message: the token is proven with a validation
# refusal. Pass --send to deliver one real message to Sam.
#
# Read-only against the school (AGENTS.md §10 rule 1): nothing here calls the
# school. /ask reads SQLite only.
set -u
BASE="${SCHOOL_VERIFY_BASE:-http://127.0.0.1:8792}"
ENV_FILE="${SCHOOL_ENV_FILE:-environment.d/school-service.env}"
SEND=0
[ "${1:-}" = "--send" ] && SEND=1
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fail=0
pass() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
skip() { printf 'SKIP %s\n' "$1"; }
token="${SCHOOL_HUB_TOKEN:-}"
if [ -z "$token" ] && [ -f "$ENV_FILE" ]; then
token="$(sed -n 's/^SCHOOL_HUB_TOKEN=//p' "$ENV_FILE" | head -1)"
fi
echo "== GET /health =="
code="$(curl -sS -o "$TMP/health.json" -w '%{http_code}' "$BASE/health" || echo 000)"
printf 'HTTP %s\n' "$code"
cat "$TMP/health.json" 2>/dev/null; echo
if [ "$code" = "200" ] && grep -q '"ok":true' "$TMP/health.json"; then
pass "/health 200 with ok true"
else
bad "/health did not return 200 ok true"
fi
echo
echo "== GET /capabilities without a token =="
code="$(curl -sS -o "$TMP/caps401.json" -w '%{http_code}' "$BASE/capabilities" || echo 000)"
if [ "$code" = "401" ]; then
pass "/capabilities refused an unsigned request"
elif [ "$code" = "503" ]; then
bad "/capabilities answered 503: SCHOOL_HUB_TOKEN is not set on the service"
else
bad "/capabilities answered $code without a token, want 401"
fi
if [ -z "$token" ]; then
skip "no SCHOOL_HUB_TOKEN in the environment or $ENV_FILE; the authenticated checks cannot run"
else
echo
echo "== GET /capabilities =="
code="$(curl -sS -H "Authorization: Bearer $token" -o "$TMP/caps.json" -w '%{http_code}' "$BASE/capabilities" || echo 000)"
printf 'HTTP %s\n' "$code"
cat "$TMP/caps.json" 2>/dev/null; echo
if [ "$code" = "200" ] && grep -q '"project":"compass_nhs"' "$TMP/caps.json"; then
pass "/capabilities names compass_nhs"
else
bad "/capabilities did not name compass_nhs (HTTP $code)"
fi
echo
echo "== POST /ask: what does Harry have on today at school? =="
code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
-d '{"question":"what does Harry have on today at school?","asker":"sam","person":"harry","source":"esp32"}' \
-o "$TMP/ask.json" -w '%{http_code}' "$BASE/ask" || echo 000)"
printf 'HTTP %s\n' "$code"
cat "$TMP/ask.json" 2>/dev/null; echo
if [ "$code" = "200" ] && grep -q '"answer":"' "$TMP/ask.json"; then
pass "/ask returned an answer"
else
bad "/ask did not return an answer (HTTP $code)"
fi
echo
echo "== POST /notify: token proven without delivering =="
code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
-d '{"from":"verify","level":"nonsense","message":"validation probe","person":"Sam"}' \
-o "$TMP/notify.json" -w '%{http_code}' "$BASE/notify" || echo 000)"
printf 'HTTP %s\n' "$code"
cat "$TMP/notify.json" 2>/dev/null; echo
if [ "$code" = "400" ]; then
pass "/notify rejected an invalid level (auth worked, nothing delivered)"
else
bad "/notify answered $code for a bad level, want 400"
fi
if [ "$SEND" = "1" ]; then
echo
echo "== POST /notify: real delivery to Sam (--send) =="
code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
-d '{"from":"verify-phase8","level":"info","message":"Compass / NHS Hub API check.","person":"Sam"}' \
-o "$TMP/send.json" -w '%{http_code}' "$BASE/notify" || echo 000)"
printf 'HTTP %s\n' "$code"
cat "$TMP/send.json" 2>/dev/null; echo
if [ "$code" = "200" ] && grep -q '"ok":true' "$TMP/send.json"; then
pass "/notify delivered a real message"
else
bad "/notify did not deliver (HTTP $code)"
fi
else
skip "real /notify delivery not attempted; rerun with --send"
fi
fi
echo
if [ "$fail" = "0" ]; then
echo "RESULT: Phase 8 verified"
else
echo "RESULT: Phase 8 verification FAILED"
fi
exit "$fail"

View File

@@ -10,8 +10,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.5.0-phase7" VERSION: "0.6.0-phase8"
image: compass-teams-nhs/school:0.5.0-phase7 image: compass-teams-nhs/school:0.6.0-phase8
container_name: compass-teams-nhs container_name: compass-teams-nhs
restart: unless-stopped restart: unless-stopped
env_file: env_file:

202
docs/phase-8-hub-notes.md Normal file
View File

@@ -0,0 +1,202 @@
# Phase 8 — Hub API: `/capabilities`, `/ask` (read-only), `/notify`
Status: **built, deployed and proven against the live service** (2026-10-15).
The live-Hub registration is the one remaining step; see *The Hub wiring* below.
Spec: `AGENTS.md` §4 FR-11, §8 (integrations), §10 rule 5 (`/ask` is read-only),
§11 Phase 8. Contract: `central_ai_llm_master/docs/ask-contract.md` (frozen).
## What the phase does
The service exposes the project side of the frozen Hub contract on its own port
(8792). The Hub reads `GET /capabilities` to route a question, calls `POST /ask`
to get one short answer, and may call `POST /notify` to have a person told
something. All three need the shared bearer token.
| Method | Path | Body / reply | Write? |
|---|---|---|---|
| `GET` | `/capabilities` | `{"project":"compass_nhs","topics":[…],"people":["harry","finn"]}` | no |
| `POST` | `/ask` | request `{"question","asker","person","source"}`; reply `{"answer","data","expires"}` or `{"answer":null,"reason"}` | **no** |
| `POST` | `/notify` | request `{"from","level","message","person"}`; reply `{"ok":true}` | Apprise only |
`GET /health` now also carries `"ok": true` and `"hub_api": true`, so the Hub's
existing health shape and this service's richer shape both work.
## Configuration
| Variable | Meaning |
|---|---|
| `SCHOOL_HUB_TOKEN` | Shared bearer token the Hub sends. Empty turns the three endpoints off with `503`; the dashboard is unaffected. |
The token is a secret. It lives only in `environment.d/school-service.env`
(gitignored). The Hub holds the same value as `HUB_PROJECT_COMPASS_NHS_TOKEN` in
`~/.config/environment.d/10-secrets.conf` and reaches this service through
`HUB_PROJECTS=compass_nhs=http://192.168.20.13:8792`.
## `/ask` — how the answer is built
The answers are deterministic. No model is on the path, so the Hub's 3 second
cap is never the reason an answer is missing. `/ask` reads only:
- `children` (find the person by name, case-insensitively),
- `items` (the child's stored Compass items),
- `scans` (the child's scan health).
It reuses `dashboard.Classify`, the same code that builds the dashboard and the
morning briefs, so the answer and the page never disagree.
The question picks a focus by plain keywords:
| The question mentions | The answer covers |
|---|---|
| "overdue", "not done", "behind" | overdue tasks |
| "this week" | overdue, then due this week |
| "notice", "message", "news" | school notices |
| "today", "timetable", "schedule", "class", "at school" | today's lessons, tasks due today, overdue |
| "due", "homework", "assessment", "exam" or anything else | the whole picture |
The reply is **one short sentence**, for example:
```
Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.
```
The structured detail (per bucket, with subject, teacher and due time) is in
`data`; the Hub may ignore it. `expires` is the end of the family day.
### Fail loudly (FR-12, AGENTS.md §10 rule 2)
A failed or missing scan is **never** an empty day. `/ask` answers:
- no scan yet — `I have no school data for Harry yet: the Compass scan has not run, so due work is unknown.`
- last scan failed — `I cannot say what Harry has due: the last Compass scan on <time> failed, so due work is unknown.`
- last scan older than 36 hours — the normal answer plus `The last Compass scan for Harry was N days ago, so this may be out of date.`
`data.scan_ok` and `data.scan_stale` carry the same state for a caller that does
not read the sentence.
### Read-only
`internal/hub` depends on a `Reader` interface with three read methods and no
write method, so `/ask` and `/capabilities` cannot write. `TestHubAskReadsOnly`
also asserts that the `items`, `scans` and `notifications` row counts do not
change across `/ask` calls.
### Permission
The contract's rule 2 is enforced: a parent (`SCHOOL_APPRISE_PARENTS`, default
Jo and Sam) may ask about any child, and anyone else may ask about themselves
only. An unknown person returns `answer: null` with a reason; no data is leaked
between the children.
## `/notify` — the interpretation
The contract defines `POST /notify` as project-to-Hub (the *project* posts to the
Hub so the Hub can notify a person). `AGENTS.md` FR-11 places `/notify` on this
service's Hub API. This phase follows the house interpretation already used by
the sibling `internet_use_control` service: this service **accepts** the
contract's notification shape and delivers it through Apprise to the named
person, and records every attempt in `notifications` with `scope = 'hub'`.
- An empty `person` defaults to Sam (the service owner).
- An unknown `person` is refused with `400` rather than sent to the wrong inbox.
- `level` maps to the Apprise class: `info` → info, `important` → warning,
`urgent` → failure.
- A delivery failure answers `502` and is recorded with `ok = 0`, no `sent_at`,
and the redacted error (FR-12).
This is the one write on the Hub surface. It never changes school data and never
touches the school.
## Commands
```bash
# The Phase 8 definition of done: /health, /capabilities, /ask, /notify auth.
make verify-phase8
# Also deliver one real /notify message to Sam.
deploy/verify-phase8.sh --send
```
## Evidence (2026-10-15, 08:46–08:47 UTC / 19:46–19:47 AEDT)
`deploy/verify-phase8.sh` on `.13` against `http://127.0.0.1:8792`:
```
== GET /health ==
HTTP 200
{"ok":true,"status":"ok","service":"compass-teams-nhs","version":"0.6.0-phase8",
"database":"ok","scans":{…,"ok":true},"hub_api":true,"time":"2026-10-15T08:46:56Z"}
PASS /health 200 with ok true
== GET /capabilities without a token ==
PASS /capabilities refused an unsigned request
== GET /capabilities ==
HTTP 200
{"project":"compass_nhs","topics":["school","compass","homework","timetable",
"class","overdue","school notices"],"people":["harry","finn"]}
PASS /capabilities names compass_nhs
== POST /ask: what does Harry have on today at school? ==
HTTP 200
{"answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
"data":{"child":"Harry","scan_ok":true,"scan_stale":false,"scan_age_hours":0,…},
"expires":"2026-10-15T13:00:00Z"}
PASS /ask returned an answer
== POST /notify: token proven without delivering ==
HTTP 400
{"ok":false,"error":"level must be info, important or urgent"}
PASS /notify rejected an invalid level (auth worked, nothing delivered)
== POST /notify: real delivery to Sam (--send) ==
HTTP 200
{"ok":true}
PASS /notify delivered a real message
```
The delivery log confirms both channels:
```
hub ask ... question="what does Harry have on today at school?" answered=true duration_ms=1
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=email status=200
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=whatsapp status=200
```
## The Hub wiring (remaining)
The Hub container (`central-ai-hub:local`, port 8788) currently runs with
`HUB_PROJECTS` empty, so it has no data project to route to. Registering this
service and restarting the Hub is a change to a running, family-used service and
a change to `~/.config/environment.d/10-secrets.conf`, so it is done only with a
recorded approval.
The change is exactly:
1. Add to `~/.config/environment.d/10-secrets.conf`:
`HUB_PROJECT_COMPASS_NHS_TOKEN=<the value of SCHOOL_HUB_TOKEN in this project's environment.d/school-service.env>`.
2. Set `HUB_PROJECTS` there to include
`compass_nhs=http://192.168.20.13:8792` (keep the existing `email_agent` entry).
3. Restart the `hub` container: `docker compose -f /home/sam/paseo/projects/central_ai_llm_master/docker-compose.yml up -d hub`.
Then the end-to-end check is:
```bash
curl -sS -H "Authorization: Bearer $HUB_TOKEN_CONSOLE" -H 'Content-Type: application/json' \
-d '{"text":"what does Harry have on today at school?","asker":"sam","source":"console"}' \
http://192.168.20.13:8788/ask
# expect route.kind = project, route.project = compass_nhs, status ok
```
## Decisions and notes
1. **The Hub-facing project name is `compass_nhs`** — the row already in the
Hub's human-owned `routing.md`. Changing the name would mean editing the Hub.
2. **Deterministic answers, no LLM.** The contract caps the call at 3 seconds
and a school question must answer on the first try. The sibling project made
the same choice.
3. **One sentence** for the answer, per the contract. The parenthetical stale
warning is the only second sentence, and only when the data is old.
4. **The token is one shared secret per project**, per the contract's
Authentication section.

View File

@@ -74,3 +74,15 @@ SCHOOL_APPRISE_PARENTS=Jo,Sam
# Generate one and keep it here: # Generate one and keep it here:
# docker run --rm --entrypoint /app/school <image> gen-secret # docker run --rm --entrypoint /app/school <image> gen-secret
SCHOOL_NOTIFY_TOKEN= SCHOOL_NOTIFY_TOKEN=
# ---- Hub API (AGENTS.md §4 FR-11, §11 Phase 8) ------------------------------
# The shared token the Hub sends to GET /capabilities, POST /ask (read-only) and
# POST /notify. It is a SECRET: keep it in this file only, never in git.
#
# The Hub holds the same value as HUB_PROJECT_COMPASS_NHS_TOKEN in
# ~/.config/environment.d/10-secrets.conf, and reaches this service through
# HUB_PROJECTS=compass_nhs=http://192.168.20.13:8792. Generate one with:
# head -c 32 /dev/urandom | base64 | tr -d '=+/' | cut -c1-40
# An empty value turns the three Hub endpoints off with 503; the dashboard is
# unaffected.
SCHOOL_HUB_TOKEN=

View File

@@ -49,6 +49,16 @@ type Config struct {
// Apprise is the notification gateway for the morning briefs (FR-9, // Apprise is the notification gateway for the morning briefs (FR-9,
// FR-14). // FR-14).
Apprise AppriseConfig Apprise AppriseConfig
// HubToken authenticates the Hub API (AGENTS.md §4 FR-11):
// GET /capabilities, POST /ask and POST /notify. It is a shared
// service-to-service secret; the Hub sends it. When it is empty those
// endpoints answer 503 and nothing else changes.
HubToken string
}
// HubConfigured reports whether the Hub API can serve authenticated requests.
func (c *Config) HubConfigured() bool {
return strings.TrimSpace(c.HubToken) != ""
} }
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes // CalendarConfig points at Sam's Home Assistant calendar. Every value comes
@@ -271,6 +281,7 @@ func Load() (*Config, error) {
} }
cfg.Location = loc cfg.Location = loc
cfg.Calendar = loadCalendar() cfg.Calendar = loadCalendar()
cfg.HubToken = strings.TrimSpace(os.Getenv("SCHOOL_HUB_TOKEN"))
apprise, err := loadApprise() apprise, err := loadApprise()
if err != nil { if err != nil {
return nil, err return nil, err

498
internal/hub/hub.go Normal file
View File

@@ -0,0 +1,498 @@
// Package hub implements the project side of the frozen Hub contract
// (central_ai_llm_master/docs/ask-contract.md) for the Compass / NHS service:
//
// GET /capabilities -> the topics this project can answer (Hub routing)
// POST /ask -> a short, read-only answer about school work
// POST /notify -> accept the contract's notification shape
//
// The Hub is the expert on routing and reply shape. It must never learn this
// project's data model, so /ask answers in one short sentence and never exposes
// a token, a path or a database id. /ask and /capabilities are read-only: they
// call only store read methods, so "read only" is a testable property
// (TestAskReadsOnly), not a comment (AGENTS.md §10 rule 5, FR-11).
//
// The answers are deterministic. No model is in the path: the Hub already caps
// the call at 3 seconds, and a school question must answer on the first try.
package hub
import (
"context"
"fmt"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// ProjectName is the value the Hub matches in routing.md. The Hub's topic map
// (and central_ai_llm_master/routing.md) already names this project
// `compass_nhs`, and the contract requires /capabilities to report the same
// name, so this is the Hub-facing name. The repository, module and service keep
// the fuller "Compass / NHS" name.
const ProjectName = "compass_nhs"
// DefaultStaleAfter is how old the last successful scan may be before /ask says
// so. The 6:00am schedule scans daily, so a day and a half means the schedule
// did not run (AGENTS.md §13 decision 7).
const DefaultStaleAfter = 36 * time.Hour
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
// question to this project. Keep the topic list short and plain; it holds no
// private data.
type Capabilities struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
// DefaultCapabilities returns the topics this project answers. They match the
// school domain in AGENTS.md §1 and §4 and the Hub's routing.md row.
func DefaultCapabilities() Capabilities {
return Capabilities{
Project: ProjectName,
Topics: []string{"school", "compass", "homework", "timetable", "class", "overdue", "school notices"},
People: []string{"harry", "finn"},
}
}
// AskRequest is the POST /ask body (ask-contract.md).
type AskRequest struct {
Question string `json:"question"`
Asker string `json:"asker"`
Person string `json:"person,omitempty"`
Source string `json:"source,omitempty"`
}
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
// answer and Reason explains why. The shape is frozen by the contract.
type AskResponse struct {
Answer *string `json:"answer"`
Data map[string]any `json:"data,omitempty"`
Expires string `json:"expires,omitempty"`
Reason string `json:"reason,omitempty"`
}
// NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The
// contract defines it as project-to-Hub; this service accepts the same shape so
// the Hub (or a future caller) can have a person notified without inventing a
// second shape. Delivery is the only write on this surface.
type NotifyRequest struct {
From string `json:"from"`
Level string `json:"level"`
Message string `json:"message"`
Person string `json:"person,omitempty"`
}
// Reader is the read-only slice of the store that /ask and /capabilities use.
// It is an interface so the answer logic is testable without a database, and so
// the "read only" promise is enforced by the type: there is no write method to
// call.
type Reader interface {
ListChildren(ctx context.Context) ([]store.Child, error)
ItemsForChild(ctx context.Context, childID int64) ([]store.Item, error)
ScanSummaryForChild(ctx context.Context, childID int64) (store.ScanStatus, error)
}
// Answerer answers /ask from stored school items. It holds no state of its own
// beyond the clock, so the same instance serves every request.
type Answerer struct {
reader Reader
loc *time.Location
// Now is the clock. It is a field so tests are deterministic.
Now func() time.Time
// StaleAfter is how old the last scan may be before the answer says so.
// Zero means DefaultStaleAfter.
StaleAfter time.Duration
// Parents are the askers who may see any child. Every other asker may see
// only themselves (ask-contract.md, "Rules for the project" rule 2).
Parents []string
}
// NewAnswerer builds an answerer for the given reader and family timezone.
func NewAnswerer(reader Reader, loc *time.Location) *Answerer {
if loc == nil {
loc = time.UTC
}
return &Answerer{
reader: reader,
loc: loc,
Now: time.Now,
StaleAfter: DefaultStaleAfter,
Parents: []string{"sam", "jo"},
}
}
// SetParents replaces the askers who may see any child.
func (a *Answerer) SetParents(names ...string) {
clean := make([]string, 0, len(names))
for _, n := range names {
if n = normalize(n); n != "" {
clean = append(clean, n)
}
}
if len(clean) > 0 {
a.Parents = clean
}
}
// Answer answers one question about this project's own data. It always returns
// a value: either an answer or a reason. It never guesses and never returns an
// answer for a question it cannot place.
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
if strings.TrimSpace(req.Question) == "" {
return cannotAnswer("the question is required")
}
asker := normalize(req.Asker)
if asker == "" {
return cannotAnswer("the asker is required")
}
person := normalize(req.Person)
if person == "" {
person = asker
}
children, err := a.reader.ListChildren(ctx)
if err != nil {
return cannotAnswer("the school data could not be read")
}
child := findChild(children, person)
if child == nil {
return cannotAnswer("no data for that person")
}
if !a.maySee(asker, child.Name) {
return cannotAnswer("that person is not allowed to ask about " + child.Name)
}
items, err := a.reader.ItemsForChild(ctx, child.ID)
if err != nil {
return cannotAnswer("the school data could not be read")
}
scan, err := a.reader.ScanSummaryForChild(ctx, child.ID)
if err != nil {
return cannotAnswer("the scan state could not be read")
}
now := a.Now().In(a.loc)
buckets := dashboard.Classify(items, now, a.loc)
state := scanHealthOf(scan, now, a.staleAfter())
resp := a.compose(child, buckets, state, req.Question, now)
return resp
}
// scanHealth is the fail-loudly view of the scan for one child (FR-12). It never
// lets a failed or missing scan read as "nothing due".
type scanHealth struct {
OK bool
EverRun bool
Stale bool
Age time.Duration
At *time.Time
Error string
}
func scanHealthOf(st store.ScanStatus, now time.Time, staleAfter time.Duration) scanHealth {
s := scanHealth{EverRun: st.EverRun}
if st.Last == nil {
return s
}
at := st.Last.StartedAt.In(now.Location())
s.At = &at
s.Age = now.Sub(at)
if !st.Last.OK {
s.Error = strings.TrimSpace(st.Last.Error)
return s
}
s.OK = true
s.Stale = staleAfter > 0 && s.Age > staleAfter
return s
}
// compose turns one child's buckets into the contract's reply. The answer is
// one short sentence (with a second sentence only when the data is stale), and
// the structured detail lives in `data` for the Hub to ignore or use.
func (a *Answerer) compose(child *store.Child, b dashboard.Buckets, scan scanHealth, question string, now time.Time) AskResponse {
data := map[string]any{
"child": child.Name,
"scan_ok": scan.OK,
"scan_stale": scan.Stale,
}
if scan.At != nil {
data["scan_at"] = scan.At.UTC().Format(time.RFC3339)
}
if scan.Age > 0 {
data["scan_age_hours"] = int(scan.Age.Hours())
}
if scan.Error != "" {
data["scan_error"] = scan.Error
}
// Fail loudly: a missing or failed scan is reported as its own answer, never
// as an empty day (FR-12, AGENTS.md §10 rule 2).
if !scan.OK {
return AskResponse{
Answer: answerText(unavailableAnswer(child.Name, scan)),
Data: data,
}
}
data["overdue"] = itemList(b.Overdue, a.loc)
data["due_today"] = itemList(b.DueToday, a.loc)
data["due_week"] = itemList(b.DueWeek, a.loc)
data["messages"] = itemList(b.Messages, a.loc)
text := a.sentence(child.Name, b, focusFor(question), a.loc)
if scan.Stale {
text += " " + staleNote(child.Name, scan.Age)
}
return AskResponse{
Answer: answerText(text),
Data: data,
Expires: endOfDay(now).UTC().Format(time.RFC3339),
}
}
// focus is the part of the data the question asks about.
type focus int
const (
focusAll focus = iota
focusToday
focusWeek
focusOverdue
focusMessages
)
// focusFor reads the question. It is deliberately simple: the Hub routes the
// question, so this project only has to place the few school shapes. An unknown
// question still answers with the whole picture rather than guessing a bucket.
func focusFor(question string) focus {
q := strings.ToLower(question)
switch {
case contains(q, "overdue", "not done", "outstanding", "late work", "behind"):
return focusOverdue
case contains(q, "this week", "next week", "week"):
return focusWeek
case contains(q, "message", "notice", "news", "email", "announcement"):
return focusMessages
case contains(q, "today", "timetable", "schedule", "class", "period", "lesson", "at school", "on at school"):
return focusToday
case contains(q, "due", "homework", "assignment", "assessment", "task", "sac", "exam"):
return focusAll
default:
return focusAll
}
}
// sentence builds the one-sentence answer for a focus. Every part is a phrase
// ("8 classes today from 8:55am", "2 overdue tasks"), so the joined sentence
// stays grammatical.
func (a *Answerer) sentence(name string, b dashboard.Buckets, f focus, loc *time.Location) string {
events := onlyType(b.DueToday, "event")
today := onlyType(b.DueToday, "task")
var parts []string
add := func(s string) {
if s != "" {
parts = append(parts, s)
}
}
overdue := countPhrase(len(b.Overdue), "overdue task")
week := countPhrase(len(b.DueWeek), "task due this week")
switch f {
case focusOverdue:
add(overdue)
if len(parts) == 0 {
add(countPhrase(len(today), "task due today"))
}
case focusWeek:
add(overdue)
add(week)
case focusMessages:
add(countPhrase(len(b.Messages), "school notice"))
case focusToday:
add(eventsPhrase(events, loc))
add(countPhrase(len(today), "task due today"))
add(overdue)
default: // focusAll
add(eventsPhrase(events, loc))
add(countPhrase(len(today), "task due today"))
add(overdue)
add(week)
}
if len(parts) == 0 {
return name + " has nothing due today."
}
return name + " has " + joinParts(parts) + "."
}
// eventsPhrase is the today's-schedule part: "8 classes today from 8:55am".
func eventsPhrase(events []store.Item, loc *time.Location) string {
if len(events) == 0 {
return ""
}
first, last := time.Time{}, time.Time{}
for _, it := range events {
if it.DueAt == nil {
continue
}
t := it.DueAt.In(loc)
if first.IsZero() || t.Before(first) {
first = t
}
if last.IsZero() || t.After(last) {
last = t
}
}
word := "events"
if len(events) == 1 {
word = "event"
}
if first.IsZero() {
return fmt.Sprintf("%d school %s today", len(events), word)
}
if first.Format("15:04") == last.Format("15:04") {
return fmt.Sprintf("%d school %s today from %s", len(events), word, clock(first))
}
return fmt.Sprintf("%d school %s today from %s to %s", len(events), word, clock(first), clock(last))
}
// unavailableAnswer is the fail-loudly sentence for a missing or failed scan.
func unavailableAnswer(name string, scan scanHealth) string {
if !scan.EverRun {
return fmt.Sprintf("I have no school data for %s yet: the Compass scan has not run, so due work is unknown.", name)
}
if scan.At != nil {
return fmt.Sprintf("I cannot say what %s has due: the last Compass scan on %s failed, so due work is unknown.",
name, scan.At.In(time.UTC).Format("Mon 02 Jan 15:04 MST"))
}
return fmt.Sprintf("I cannot say what %s has due: the last Compass scan failed, so due work is unknown.", name)
}
// staleNote qualifies an answer built on old data, so an out-of-date list is
// never read as today's work.
func staleNote(name string, age time.Duration) string {
days := int(age.Hours() / 24)
if days >= 2 {
return fmt.Sprintf("The last Compass scan for %s was %d days ago, so this may be out of date.", name, days)
}
return fmt.Sprintf("The last Compass scan for %s was %d hours ago, so this may be out of date.", name, int(age.Hours()))
}
// maySee checks the contract's permission rule: a parent may see any child, and
// anyone else may see only themselves.
func (a *Answerer) maySee(asker, child string) bool {
a2, c := normalize(asker), normalize(child)
if a2 == c {
return true
}
for _, p := range a.Parents {
if normalize(p) == a2 {
return true
}
}
return false
}
func (a *Answerer) staleAfter() time.Duration {
if a.StaleAfter > 0 {
return a.StaleAfter
}
return DefaultStaleAfter
}
// findChild matches a name case-insensitively: the Hub lower-cases names, while
// the database stores "Harry".
func findChild(children []store.Child, name string) *store.Child {
for i := range children {
if normalize(children[i].Name) == name {
return &children[i]
}
}
return nil
}
func onlyType(items []store.Item, typ string) []store.Item {
out := make([]store.Item, 0, len(items))
for _, it := range items {
if it.Type == typ {
out = append(out, it)
}
}
return out
}
// itemList is the structured detail for `data`. It holds no secret and no path.
func itemList(items []store.Item, loc *time.Location) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
m := map[string]any{
"type": it.Type,
"title": strings.TrimSpace(it.Title),
}
if s := strings.TrimSpace(it.Subject); s != "" {
m["subject"] = s
}
if t := strings.TrimSpace(it.Teacher); t != "" {
m["teacher"] = t
}
if it.DueAt != nil {
m["due_at"] = it.DueAt.In(loc).Format(time.RFC3339)
}
out = append(out, m)
}
return out
}
func countPhrase(n int, noun string) string {
switch {
case n == 0:
return ""
case n == 1:
return "1 " + noun
default:
return fmt.Sprintf("%d %ss", n, noun)
}
}
// joinParts renders "a", "a and b" or "a, b and c".
func joinParts(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
case 2:
return parts[0] + " and " + parts[1]
default:
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
}
}
func contains(haystack string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(haystack, n) {
return true
}
}
return false
}
func clock(t time.Time) string { return t.Format("3:04pm") }
func endOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
}
func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// answerText is a small helper so pointer handling stays in one place.
func answerText(s string) *string { return &s }
// cannotAnswer builds the contract's "cannot answer" reply.
func cannotAnswer(reason string) AskResponse {
return AskResponse{Answer: nil, Reason: reason}
}

244
internal/hub/hub_test.go Normal file
View File

@@ -0,0 +1,244 @@
package hub
import (
"context"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// fakeReader is the read-only Reader backed by fixed data. It has no write
// method, which is how the "read only" contract is enforced on /ask.
type fakeReader struct {
children []store.Child
items map[int64][]store.Item
scans map[int64]store.ScanStatus
}
func (f *fakeReader) ListChildren(context.Context) ([]store.Child, error) {
return f.children, nil
}
func (f *fakeReader) ItemsForChild(_ context.Context, childID int64) ([]store.Item, error) {
return f.items[childID], nil
}
func (f *fakeReader) ScanSummaryForChild(_ context.Context, childID int64) (store.ScanStatus, error) {
return f.scans[childID], nil
}
// testNow is a fixed Wednesday afternoon in the family timezone.
var testNow = time.Date(2026, 10, 15, 12, 0, 0, 0, mustSydney())
func mustSydney() *time.Location {
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
panic(err)
}
return loc
}
func at(y int, mo time.Month, d, h, mi int) *time.Time {
t := time.Date(y, mo, d, h, mi, 0, 0, mustSydney())
return &t
}
func okScan(t time.Time) store.ScanStatus {
return store.ScanStatus{EverRun: true, Total: 3, Last: &store.Scan{ID: 3, StartedAt: t, OK: true}}
}
func testAnswerer(r Reader) *Answerer {
a := NewAnswerer(r, mustSydney())
a.Now = func() time.Time { return testNow }
return a
}
// harryData is one realistic day: eight lessons, one task due today, two
// overdue SACs, one task this week and one notice.
func harryData() *fakeReader {
return &fakeReader{
children: []store.Child{{ID: 1, Name: "Harry"}, {ID: 2, Name: "Finn"}},
items: map[int64][]store.Item{1: {
{ID: 1, ChildID: 1, Type: "event", Title: "8:55: 11XMUSA6 - A1 - JBR", DueAt: at(2026, 10, 15, 8, 55)},
{ID: 2, ChildID: 1, Type: "event", Title: "9:50: 11AACPA4 - C1 - BDR", DueAt: at(2026, 10, 15, 9, 50)},
{ID: 3, ChildID: 1, Type: "event", Title: "11:00: 11SBIOA1 - M4 - AFR", DueAt: at(2026, 10, 15, 11, 0)},
{ID: 4, ChildID: 1, Type: "event", Title: "14:00: 11EENGB3 - H3 - CHA", DueAt: at(2026, 10, 15, 14, 0)},
{ID: 5, ChildID: 1, Type: "event", Title: "18:00: Senior Music Concert - HALL", DueAt: at(2026, 10, 15, 18, 0)},
{ID: 6, ChildID: 1, Type: "task", Subject: "11EENG ENGLISH", Title: "SAC 3: Responding to Texts",
Body: "Status: Pending\nDue: 2026-10-15 16:00", DueAt: at(2026, 10, 15, 16, 0)},
{ID: 7, ChildID: 1, Type: "task", Subject: "11HLEG LEGAL STUDIES", Title: "SAC 6: Human Rights",
Body: "Status: Overdue", DueAt: at(2026, 10, 9, 16, 0)},
{ID: 8, ChildID: 1, Type: "task", Subject: "11XMUS MUSIC", Title: "SAC 1: Performing",
Body: "Status: Overdue", DueAt: at(2026, 10, 5, 16, 0)},
{ID: 9, ChildID: 1, Type: "task", Subject: "11MGMA MATHS", Title: "SAC 4: Networks",
Body: "Status: Pending", DueAt: at(2026, 10, 19, 16, 0)},
{ID: 10, ChildID: 1, Type: "message", Title: "Health Information", DueAt: at(2026, 10, 13, 9, 0)},
}},
scans: map[int64]store.ScanStatus{1: okScan(testNow.Add(-4 * time.Hour))},
}
}
func answerOf(t *testing.T, a *Answerer, req AskRequest) string {
t.Helper()
resp := a.Answer(context.Background(), req)
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
return *resp.Answer
}
func TestTodayQuestion(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{
Question: "what does Harry have on today at school?",
Asker: "sam",
Person: "harry",
Source: "esp32",
})
// 5 lessons, 1 task due today, 2 overdue.
if !strings.Contains(got, "5 school events today from 8:55am to 6:00pm") {
t.Errorf("answer = %q, want the lesson count and span", got)
}
if !strings.Contains(got, "1 task due today") {
t.Errorf("answer = %q, want 1 task due today", got)
}
if !strings.Contains(got, "2 overdue tasks") {
t.Errorf("answer = %q, want 2 overdue tasks", got)
}
if strings.Count(got, ".") != 1 {
t.Errorf("answer = %q, want one sentence", got)
}
}
func TestTodayQuestionIsCorrectForRealShape(t *testing.T) {
// The Hub lower-cases the person, and the status line uses the HTML grid
// prefix. Both must resolve the same way.
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what has harry got on today?", Asker: "sam", Person: "Harry"})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", resp.Data["child"])
}
if resp.Expires == "" {
t.Error("expires is empty, want end of day")
}
}
func TestOverdueFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "does Harry have any overdue work?", Asker: "sam", Person: "harry"})
if got != "Harry has 2 overdue tasks." {
t.Errorf("answer = %q, want the overdue count only", got)
}
}
func TestMessagesFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "any school notices for Harry?", Asker: "sam", Person: "harry"})
if got != "Harry has 1 school notice." {
t.Errorf("answer = %q, want one notice", got)
}
}
func TestWeekFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "what is due this week for Harry?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "1 task due this week") {
t.Errorf("answer = %q, want the week count", got)
}
}
// TestFailedScanIsLoud is the FR-12 promise: a failed scan is never an empty
// day. The answer says due work is unknown.
func TestFailedScanIsLoud(t *testing.T) {
r := harryData()
r.scans[1] = store.ScanStatus{EverRun: true, Total: 4, Failed: 1,
Last: &store.Scan{ID: 4, StartedAt: testNow.Add(-2 * time.Hour), OK: false, Error: "cloudflare challenge"}}
a := testAnswerer(r)
resp := a.Answer(context.Background(), AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if resp.Answer == nil {
t.Fatal("answer = nil, want a loud answer")
}
got := *resp.Answer
if !strings.Contains(got, "failed") || !strings.Contains(got, "unknown") {
t.Errorf("answer = %q, want the failed scan stated", got)
}
if strings.Contains(got, "nothing due") {
t.Errorf("answer = %q, must not read as nothing due", got)
}
if resp.Data["scan_ok"] != false {
t.Errorf("data.scan_ok = %v, want false", resp.Data["scan_ok"])
}
}
func TestNeverScannedIsLoud(t *testing.T) {
r := harryData()
r.scans[1] = store.ScanStatus{}
a := testAnswerer(r)
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "has not run") {
t.Errorf("answer = %q, want the missing scan stated", got)
}
}
func TestStaleScanIsQualified(t *testing.T) {
r := harryData()
r.scans[1] = okScan(testNow.Add(-50 * time.Hour))
a := testAnswerer(r)
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "may be out of date") {
t.Errorf("answer = %q, want a stale warning", got)
}
}
func TestUnknownPersonCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what does Hogwarts have on today?", Asker: "sam"})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil", *resp.Answer)
}
if resp.Reason == "" {
t.Error("reason is empty, want an explanation")
}
}
func TestChildCannotAskAboutSibling(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what does Finn have on today?", Asker: "harry", Person: "finn"})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil", *resp.Answer)
}
if !strings.Contains(resp.Reason, "not allowed") {
t.Errorf("reason = %q, want a permission refusal", resp.Reason)
}
}
func TestChildMayAskAboutThemselves(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what do I have on today?", Asker: "harry", Person: "harry"})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
}
func TestEmptyQuestionCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Asker: "sam"})
if resp.Answer != nil || resp.Reason == "" {
t.Errorf("resp = %+v, want cannot-answer", resp)
}
}
func TestCapabilitiesNameMatchesRouting(t *testing.T) {
caps := DefaultCapabilities()
if caps.Project != "compass_nhs" {
t.Errorf("project = %q, want compass_nhs (the Hub routing.md row)", caps.Project)
}
if len(caps.Topics) == 0 || len(caps.People) == 0 {
t.Error("capabilities must name topics and people")
}
}

257
internal/web/hub_api.go Normal file
View File

@@ -0,0 +1,257 @@
// 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:])
}

View File

@@ -0,0 +1,290 @@
package web
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
const testHubToken = "hub-test-token-0123456789abcdef"
// hubJSON posts a JSON body to a Hub endpoint with the given bearer token.
func hubJSON(t *testing.T, ts *httptest.Server, method, path, token, body string) (*http.Response, []byte) {
t.Helper()
var rdr *bytes.Reader
if body == "" {
rdr = bytes.NewReader(nil)
} else {
rdr = bytes.NewReader([]byte(body))
}
req, err := http.NewRequest(method, ts.URL+path, rdr)
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
raw := readAll(t, resp)
return resp, raw
}
func readAll(t *testing.T, resp *http.Response) []byte {
t.Helper()
var buf bytes.Buffer
if _, err := buf.ReadFrom(resp.Body); err != nil {
t.Fatalf("read body: %v", err)
}
return buf.Bytes()
}
// addHubItem stores one item so /ask has real rows to read.
func addHubItem(t *testing.T, st *store.Store, childID int64, it store.Item) {
t.Helper()
it.ChildID = childID
if it.Source == "" {
it.Source = "compass"
}
if it.ExternalRef == "" {
it.ExternalRef = fmt.Sprintf("hub-test-%d", time.Now().UnixNano())
}
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{it}); err != nil {
t.Fatalf("upsert item %q: %v", it.Title, err)
}
}
func countRows(t *testing.T, st *store.Store, table string) int {
t.Helper()
var n int
if err := st.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
return n
}
// TestHubCapabilitiesAndAsk covers the two read-only endpoints: the routing
// topics the Hub reads, and a correct answer to the phase's question (FR-11,
// §11 Phase 8).
func TestHubCapabilitiesAndAsk(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
loc := mustLoc(t)
ctx := context.Background()
harry, err := st.ChildByName(ctx, "Harry")
if err != nil || harry == nil {
t.Fatalf("harry: %v %v", harry, err)
}
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
addHubItem(t, st, harry.ID, store.Item{
Type: "event", Subject: "11XMUSA6", Teacher: "JBR",
Title: "1 - 11XMUSA6 - A1 - JBR", DueAt: &today,
})
if _, err := st.RecordScan(ctx, &harry.ID, 5, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
// Capabilities: the Hub reads this to route.
resp, raw := hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("capabilities status = %d, body = %s", resp.StatusCode, raw)
}
var caps struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
if err := json.Unmarshal(raw, &caps); err != nil {
t.Fatalf("capabilities json: %v (%s)", err, raw)
}
if caps.Project != "compass_nhs" {
t.Errorf("project = %q, want compass_nhs", caps.Project)
}
if len(caps.Topics) == 0 || len(caps.People) == 0 {
t.Errorf("capabilities = %+v, want topics and people", caps)
}
// The phase's question.
resp, raw = hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today at school?","asker":"sam","person":"harry","source":"esp32"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ask status = %d, body = %s", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
Expires string `json:"expires"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("ask json: %v (%s)", err, raw)
}
if ask.Answer == nil {
t.Fatalf("answer = nil, reason = %q", ask.Reason)
}
if !strings.Contains(*ask.Answer, "1 school event today") {
t.Errorf("answer = %q, want today's event", *ask.Answer)
}
if ask.Expires == "" {
t.Error("expires is empty, want end of day")
}
}
// TestHubAskReadsOnly is the contract's first rule made testable: /ask and
// /capabilities must not write anything (AGENTS.md §10 rule 5).
func TestHubAskReadsOnly(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
now := time.Now()
addHubItem(t, st, harry.ID, store.Item{Type: "task", Title: "SAC 1", DueAt: &now, Body: "Status: Pending"})
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
itemsBefore := countRows(t, st, "items")
scansBefore := countRows(t, st, "scans")
notifyBefore := countRows(t, st, "notifications")
hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today?","asker":"sam","person":"harry"}`)
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what is overdue for Finn?","asker":"sam","person":"finn"}`)
if after := countRows(t, st, "items"); after != itemsBefore {
t.Errorf("items changed: %d -> %d, /ask must not write", itemsBefore, after)
}
if after := countRows(t, st, "scans"); after != scansBefore {
t.Errorf("scans changed: %d -> %d, /ask must not write", scansBefore, after)
}
if after := countRows(t, st, "notifications"); after != notifyBefore {
t.Errorf("notifications changed: %d -> %d, /ask must not write", notifyBefore, after)
}
}
// TestHubEndpointsNeedToken proves an unsigned request is never trusted, and
// that an unconfigured token turns the API off rather than opening it.
func TestHubEndpointsNeedToken(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
cases := []struct {
method, path, body string
}{
{http.MethodGet, "/capabilities", ""},
{http.MethodPost, "/ask", `{"question":"what does Harry have on today?","asker":"sam"}`},
{http.MethodPost, "/notify", `{"from":"hub","level":"info","message":"hello"}`},
}
for _, tc := range cases {
resp, raw := hubJSON(t, ts, tc.method, tc.path, "", tc.body)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s without a token = %d, want 401 (body %s)", tc.method, tc.path, resp.StatusCode, raw)
}
resp, _ = hubJSON(t, ts, tc.method, tc.path, "wrong-token", tc.body)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s with a bad token = %d, want 401", tc.method, tc.path, resp.StatusCode)
}
}
}
// TestHubDisabledWithoutToken proves an empty SCHOOL_HUB_TOKEN closes the API.
func TestHubDisabledWithoutToken(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), "")
resp, raw := hubJSON(t, ts, http.MethodPost, "/ask", "anything",
`{"question":"what does Harry have on today?","asker":"sam"}`)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 (body %s)", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("json: %v (%s)", err, raw)
}
if ask.Answer != nil || ask.Reason == "" {
t.Errorf("body = %s, want the contract's cannot-answer shape", raw)
}
}
// TestHubNotifyDelivers covers POST /notify: the contract's shape is accepted
// and the message is delivered through Apprise and recorded.
func TestHubNotifyDelivers(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"important","message":"3 school messages need a decision.","person":"Sam"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("notify status = %d, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), `"ok":true`) {
t.Errorf("body = %s, want ok true", raw)
}
if len(*tags) != 2 {
t.Errorf("apprise tags = %#v, want one per channel", *tags)
}
rows, err := st.RecentNotifications(context.Background(), 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
for _, n := range rows {
if n.Scope != "hub" || n.Recipient != "Sam" || !n.OK || n.SentAt == nil {
t.Errorf("row = %+v", n)
}
}
}
// TestHubNotifyUnknownPerson refuses to deliver to a person who is not
// configured, rather than guessing an inbox.
func TestHubNotifyUnknownPerson(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"info","message":"hello","person":"nobody"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", resp.StatusCode, raw)
}
}
// TestHubNotifyFailsLoudly proves a delivery failure is reported and recorded,
// never returned as a success (FR-12).
func TestHubNotifyFailsLoudly(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"urgent","message":"blocked","person":"Sam"}`)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 (body %s)", resp.StatusCode, raw)
}
rows, _ := st.RecentNotifications(context.Background(), 10)
if len(rows) == 0 {
t.Fatal("no rows recorded")
}
for _, n := range rows {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("row = %+v, want a recorded failure", n)
}
}
}
func mustLoc(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
return loc
}

View File

@@ -25,6 +25,7 @@ import (
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/hub"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
) )
@@ -36,12 +37,14 @@ var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers. // Server holds the dependencies for the HTTP handlers.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
store *store.Store store *store.Store
auth *auth.Manager auth *auth.Manager
tmpl *template.Template tmpl *template.Template
log *slog.Logger log *slog.Logger
brief *brief.Service brief *brief.Service
answerer *hub.Answerer
apprise *apprise.Client
} }
// New builds a Server and parses the embedded templates. // New builds a Server and parses the embedded templates.
@@ -57,7 +60,10 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
} }
client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout) client := apprise.New(cfg.Apprise.BaseURL, cfg.Apprise.Key, cfg.Apprise.Token, cfg.Apprise.Timeout)
svc := brief.New(st, client, cfg.Apprise, cfg.Location, log) svc := brief.New(st, client, cfg.Apprise, cfg.Location, log)
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc}, nil answerer := hub.NewAnswerer(st, cfg.Location)
answerer.SetParents(cfg.Apprise.Parents...)
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, brief: svc,
answerer: answerer, apprise: client}, nil
} }
// Handler returns the fully wrapped HTTP handler. // Handler returns the fully wrapped HTTP handler.
@@ -83,6 +89,13 @@ func (s *Server) Handler() http.Handler {
// machine-to-machine: a bearer token, not the dashboard session. // machine-to-machine: a bearer token, not the dashboard session.
mux.HandleFunc("POST /notify/morning", s.handleMorningNotify) mux.HandleFunc("POST /notify/morning", s.handleMorningNotify)
// The project side of the frozen Hub contract (AGENTS.md §4 FR-11, §11
// Phase 8): GET /capabilities, read-only POST /ask, POST /notify. All three
// need the shared Hub bearer token (SCHOOL_HUB_TOKEN).
mux.HandleFunc("GET /capabilities", s.handleCapabilities)
mux.HandleFunc("POST /ask", s.handleAsk)
mux.HandleFunc("POST /notify", s.handleNotify)
staticSub, err := fs.Sub(staticFS, "static") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
panic("web: embedded static dir missing: " + err.Error()) panic("web: embedded static dir missing: " + err.Error())
@@ -156,24 +169,31 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
// ---------- health ---------- // ---------- health ----------
type healthResponse struct { type healthResponse struct {
// OK mirrors the frozen contract's GET /health shape ({"ok": true}) while
// keeping the richer fields this service has always returned.
OK bool `json:"ok"`
Status string `json:"status"` Status string `json:"status"`
Service string `json:"service"` Service string `json:"service"`
Version string `json:"version"` Version string `json:"version"`
Database string `json:"database"` Database string `json:"database"`
Scans store.ScanStatus `json:"scans"` Scans store.ScanStatus `json:"scans"`
Hub bool `json:"hub_api"`
Time string `json:"time"` Time string `json:"time"`
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{ resp := healthResponse{
OK: true,
Status: "ok", Status: "ok",
Service: "compass-teams-nhs", Service: "compass-teams-nhs",
Version: s.cfg.Version, Version: s.cfg.Version,
Database: "ok", Database: "ok",
Hub: s.cfg.HubConfigured(),
Time: time.Now().UTC().Format(time.RFC3339), Time: time.Now().UTC().Format(time.RFC3339),
} }
code := http.StatusOK code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil { if err := s.store.Ping(r.Context()); err != nil {
resp.OK = false
resp.Status = "error" resp.Status = "error"
resp.Database = "error" resp.Database = "error"
code = http.StatusServiceUnavailable code = http.StatusServiceUnavailable

View File

@@ -46,8 +46,18 @@ func newTestServerNotify(t *testing.T, ap config.AppriseConfig) (*httptest.Serve
return newTestServerFull(t, config.CalendarConfig{}, ap) return newTestServerFull(t, config.CalendarConfig{}, ap)
} }
// newTestServerHub is newTestServerNotify with the Hub bearer token set.
func newTestServerHub(t *testing.T, ap config.AppriseConfig, hubToken string) (*httptest.Server, *store.Store) {
return newTestServerAll(t, config.CalendarConfig{}, ap, hubToken)
}
// newTestServerFull builds the handler with both integrations configured. // newTestServerFull builds the handler with both integrations configured.
func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) { func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig) (*httptest.Server, *store.Store) {
return newTestServerAll(t, cal, ap, "")
}
// newTestServerAll builds the handler with both integrations and the Hub token.
func newTestServerAll(t *testing.T, cal config.CalendarConfig, ap config.AppriseConfig, hubToken string) (*httptest.Server, *store.Store) {
t.Helper() t.Helper()
loc, err := time.LoadLocation("Australia/Sydney") loc, err := time.LoadLocation("Australia/Sydney")
if err != nil { if err != nil {
@@ -77,6 +87,7 @@ func newTestServerFull(t *testing.T, cal config.CalendarConfig, ap config.Appris
Location: loc, Location: loc,
Calendar: cal, Calendar: cal,
Apprise: ap, Apprise: ap,
HubToken: hubToken,
} }
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword) am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil { if err != nil {