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

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"