From 9cbfa7db9bc32edb1b3333e2d1881fe436e5ee91 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Thu, 15 Oct 2026 19:47:32 +1100 Subject: [PATCH] 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 --- Makefile | 12 +- README.md | 44 +- deploy/verify-phase8.sh | 120 ++++++ docker-compose.yml | 4 +- docs/phase-8-hub-notes.md | 202 +++++++++ environment.d/school-service.env.example | 12 + internal/config/config.go | 11 + internal/hub/hub.go | 498 +++++++++++++++++++++++ internal/hub/hub_test.go | 244 +++++++++++ internal/web/hub_api.go | 257 ++++++++++++ internal/web/hub_api_test.go | 290 +++++++++++++ internal/web/server.go | 34 +- internal/web/server_test.go | 11 + 13 files changed, 1720 insertions(+), 19 deletions(-) create mode 100755 deploy/verify-phase8.sh create mode 100644 docs/phase-8-hub-notes.md create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go create mode 100644 internal/web/hub_api.go create mode 100644 internal/web/hub_api_test.go diff --git a/Makefile b/Makefile index 50715be..c74155d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.5.0-phase7 -BUILD_VERSION ?= 0.5.0-phase7 +IMAGE ?= compass-teams-nhs/school:0.6.0-phase8 +BUILD_VERSION ?= 0.6.0-phase8 # Parse options (see scripts/parse-compass.sh). 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 \ -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: @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 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-phase8 prove the Hub API: /capabilities, /ask, /notify (--send delivers)" test: $(GO_RUN) go test ./... @@ -105,3 +106,8 @@ morning-brief: # Caddy route on .35. verify-phase1: 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) diff --git a/README.md b/README.md index da5f2bb..f87fe37 100644 --- a/README.md +++ b/README.md @@ -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, 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 -> and send via Apprise). See §11 of the spec for the phase order. +> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 8** (the +> 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 @@ -98,8 +126,8 @@ Built and deployed on `.13`: - Its own container and port **8792**, behind Caddy at `school.lab.audasmedia.com.au`. -Not built yet (later phases): the Hub API. The service itself does **not** scan -the school; n8n triggers the scan. Teams (FR-15) is a separate source. +Not built yet (later phases): Teams (FR-15) and the Phase 9 documentation set. +The service itself does **not** scan the school; n8n triggers the scan. ### Fail loudly (FR-12) @@ -120,6 +148,7 @@ internal/parse/ HTML -> structured items (tasks, messages, events) internal/llm/ OmniRoute client (importance classification only) internal/apprise/ Apprise API client (tag-based, redacts errors) 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/auth/ session login (signed cookies, CSRF) internal/web/ HTTP handlers, templates, static assets @@ -139,8 +168,8 @@ docker compose up -d --build Generate secrets with the built image: ```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.5.0-phase7 hash-password 'the-password' +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.6.0-phase8 hash-password 'the-password' ``` 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 ```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 diff --git a/deploy/verify-phase8.sh b/deploy/verify-phase8.sh new file mode 100755 index 0000000..ba1e019 --- /dev/null +++ b/deploy/verify-phase8.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 0be27b0..127a4bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.5.0-phase7" - image: compass-teams-nhs/school:0.5.0-phase7 + VERSION: "0.6.0-phase8" + image: compass-teams-nhs/school:0.6.0-phase8 container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/phase-8-hub-notes.md b/docs/phase-8-hub-notes.md new file mode 100644 index 0000000..a50c292 --- /dev/null +++ b/docs/phase-8-hub-notes.md @@ -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