Files
internet_use_control/deploy/verify-phase9.sh
Sam Rolfe 3cf353d933 Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)
Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
  deterministic, read-only Answerer. It answers screen time today/yesterday,
  what is blocked now, and device presence, scoped to one child or all kids.
  Screen time is the foreground-app total per device-day; the domain total is
  used only when a device reports no apps, so a report carrying both is never
  double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
  stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
  Apprise alert per transition: a limit hit (a device becomes blocked) or a
  device offline longer than the threshold. A never-seen device is never
  alerted, and the first observation seeds state without replaying an old
  limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
  `alert_state` (per-device dedup). New usage reads from the telemetry detail
  table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
  shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
  /notify accepts the contract's {from,level,message,person} and delivers it
  through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
  CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
  `usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
  version 0.7.0-phase9.

Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
  had 1h 22m of screen time today." (total_seconds=4925); the independent
  count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
  raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
  three report delivered=1 in `alerts`.

Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:41:07 +11:00

204 lines
8.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Phase 9 definition of done: prove the Hub surface answers correctly and that
# the Apprise alerts fire and are delivered.
#
# It exercises the deployed Control Service, not a test double:
#
# 1. GET /capabilities (the Hub's routing read)
# 2. POST /ask "how long have the kids been on screens today?"
# 3. the same total independently, from the telemetry detail table
# 4. a limit-hit alert, by locking a device through the admin API
# 5. an offline-too-long alert, by publishing a device status over MQTT
# 6. POST /notify (accepted notification -> Apprise)
#
# Steps 4-6 require `CONTROL_ALERTS_ENABLED=true` and a working Apprise
# destination. The script checks the `alerts` table for `delivered` rows, so a
# recorded-but-undelivered alert is a failure, with the delivery error shown.
#
# The device is unlocked and marked online again at the end.
#
# Usage:
# make verify-phase9
# DEVICE_ID=1 deploy/verify-phase9.sh
#
# Bounded docker commands only. Nothing shared is restarted or reconfigured.
set -euo pipefail
cd "$(dirname "$0")/.."
SERVICE_URL="${CONTROL_BASE_URL:-http://127.0.0.1:8791}"
ENV_FILE="${CONTROL_ENV_FILE:-environment.d/control-service.env}"
CLIENTS_FILE="${MQTT_CLIENTS_FILE:-deploy/mosquitto/clients.env}"
CA_FILE="${MQTT_CA_FILE:-deploy/mosquitto/tls/ca.crt}"
CONTAINER="${CONTROL_CONTAINER:-webrestrict-control}"
MQTT_IMAGE="${MQTT_IMAGE:-eclipse-mosquitto:latest}"
DEVICE_ID="${DEVICE_ID:-}"
WAIT_SECONDS="${WAIT_SECONDS:-90}"
die() { echo "verify-phase9: $*" >&2; exit 1; }
envval() { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true; }
# Give the engine at least one full poll to observe a state change before the
# script relies on the transition. Read from the deployed configuration.
INTERVAL_RAW="$(envval CONTROL_ALERT_INTERVAL)"; INTERVAL_RAW="${INTERVAL_RAW:-30s}"
INTERVAL_S="${INTERVAL_RAW%s}"
[[ "$INTERVAL_S" =~ ^[0-9]+$ ]] || INTERVAL_S=30
SETTLE_SECONDS=$((INTERVAL_S + 3))
HUB_TOKEN="${CONTROL_HUB_TOKEN:-$(envval CONTROL_HUB_TOKEN)}"
ADMIN_TOKEN="${CONTROL_ADMIN_API_TOKEN:-$(envval CONTROL_ADMIN_API_TOKEN)}"
[[ -n "$HUB_TOKEN" ]] || die "set CONTROL_HUB_TOKEN or add it to $ENV_FILE"
[[ -n "$ADMIN_TOKEN" ]] || die "set CONTROL_ADMIN_API_TOKEN or add it to $ENV_FILE"
[[ -f "$CLIENTS_FILE" ]] || die "missing device credentials $CLIENTS_FILE"
[[ -f "$CA_FILE" ]] || die "missing CA file $CA_FILE"
hub() { curl -sS -H "Authorization: Bearer $HUB_TOKEN" -H 'Content-Type: application/json' "$@"; }
admin() { curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' "$@"; }
# control_alerts prints the alerts table, newest first.
control_alerts() { timeout 30 docker exec "$CONTAINER" /app/control alerts --limit 50; }
# latest_alert_id <kind> prints the newest alert id of that kind, or empty.
latest_alert_id() {
control_alerts 2>/dev/null | awk -F'\t' -v k="$1" '$2==k {print $1}' | sort -n | tail -1
}
# wait_alert <kind> <baseline_id>: waits for a newer delivered alert.
wait_alert() {
local kind="$1" base="$2" deadline=$((SECONDS + WAIT_SECONDS)) id line status
while (( SECONDS < deadline )); do
id="$(latest_alert_id "$kind")"
if [[ -n "$id" && "$id" -gt "$base" ]]; then
line="$(control_alerts 2>/dev/null | awk -F'\t' -v i="$id" '$1==i')"
status="$(echo "$line" | cut -f3)"
if [[ "$status" == "delivered" ]]; then
echo "$line"
return 0
fi
die "alert $id ($kind) is recorded but not delivered: $line"
fi
sleep 2
done
return 1
}
publish_status() { # publish_status <status_topic> <token> <password> <payload>
timeout 60 docker run --rm --network host \
-v "$PWD/$CA_FILE:/tls/ca.crt:ro" \
--entrypoint sh "$MQTT_IMAGE" -c \
"mosquitto_pub -h 127.0.0.1 -p 8883 --cafile /tls/ca.crt \
-u '$2' -P '$3' -t '$1' -r -m '$4'" >/dev/null
}
WORK="$(mktemp -d)"
DEVICE_TOPIC=""
DEVICE_TOKEN=""
DEVICE_PASSWORD=""
cleanup() {
if [[ -n "$DEVICE_ID" ]]; then
timeout 20 curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' -d '{}' \
"$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null 2>&1 || true
fi
if [[ -n "$DEVICE_TOPIC" && -n "$DEVICE_PASSWORD" ]]; then
local now
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \
"{\"online\":true,\"at\":\"$now\",\"app\":\"verify-phase9-cleanup\"}" || true
fi
rm -rf "$WORK"
}
trap cleanup EXIT
echo "service: $SERVICE_URL"
# ---- 1. capabilities ------------------------------------------------------
echo
echo "== GET /capabilities"
code="$(hub -o "$WORK/caps.json" -w '%{http_code}' "$SERVICE_URL/capabilities")"
[[ "$code" == "200" ]] || die "capabilities returned HTTP $code: $(cat "$WORK/caps.json")"
cat "$WORK/caps.json"; echo
jq -e '.project == "internet_use_control"' "$WORK/caps.json" >/dev/null \
|| die "unexpected capabilities payload"
# ---- 2. ask ---------------------------------------------------------------
echo
echo "== POST /ask"
ASK_BODY='{"question":"how long have the kids been on screens today?","asker":"sam","source":"console"}'
code="$(hub -o "$WORK/ask.json" -w '%{http_code}' -X POST -d "$ASK_BODY" "$SERVICE_URL/ask")"
[[ "$code" == "200" ]] || die "ask returned HTTP $code: $(cat "$WORK/ask.json")"
cat "$WORK/ask.json"; echo
answer_total="$(jq -r '.data.total_seconds // empty' "$WORK/ask.json")"
[[ -n "$answer_total" ]] || die "ask returned no answer/data"
ask_day="$(jq -r '.data.day' "$WORK/ask.json")"
# ---- 3. independent count -------------------------------------------------
echo
echo "== independent count (telemetry detail table, day $ask_day)"
detail="$(timeout 30 docker exec "$CONTAINER" /app/control usage --day "$ask_day" --detail)"
echo "$detail"
indep_total="$(echo "$detail" | awk -F= '/^total_screen=/{print $2}')"
echo
echo "ask total_seconds = $answer_total"
echo "independent total = $indep_total"
[[ "$answer_total" == "$indep_total" ]] \
|| die "ask total ($answer_total) does not match the independent count ($indep_total)"
echo "PASS: the answer matches the stored telemetry"
# ---- 4. limit-hit alert ---------------------------------------------------
echo
echo "== limit-hit alert"
devices="$(admin "$SERVICE_URL/api/v1/admin/devices")"
if [[ -z "$DEVICE_ID" ]]; then
DEVICE_ID="$(echo "$devices" | jq -r '.devices[0].id // empty')"
fi
[[ -n "$DEVICE_ID" ]] || die "no device is registered"
device_json="$(admin "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID")"
DEVICE_TOPIC="$(echo "$device_json" | jq -r '.device.topic')"
DEVICE_STATUS_TOPIC="${DEVICE_TOPIC%/config}/status"
DEVICE_TOKEN="${DEVICE_TOPIC#restrict/}"; DEVICE_TOKEN="${DEVICE_TOKEN%/config}"
DEVICE_PASSWORD="$(awk -F'\t' -v t="$DEVICE_TOPIC" '$3==t {print $2}' "$CLIENTS_FILE" | head -1)"
[[ -n "$DEVICE_PASSWORD" ]] || die "no broker account for $DEVICE_TOPIC in $CLIENTS_FILE"
echo "device: $(echo "$device_json" | jq -r '.device.name') (id $DEVICE_ID)"
# Make sure the engine has seen the device unlocked, then lock it.
admin -X POST -d '{}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null
sleep "$SETTLE_SECONDS"
limit_base="$(latest_alert_id limit)"; limit_base="${limit_base:-0}"
admin -X POST -d '{"minutes":30}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/lock" >/dev/null
limit_line="$(wait_alert limit "$limit_base")" \
|| die "no delivered limit alert within ${WAIT_SECONDS}s"
echo " limit alert: $limit_line"
# ---- 5. offline-too-long alert -------------------------------------------
echo
echo "== offline-too-long alert"
offline_base="$(latest_alert_id offline)"; offline_base="${offline_base:-0}"
# An old `at` makes the device immediately past the offline threshold.
old_at="$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -v-2H +%Y-%m-%dT%H:%M:%SZ)"
publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \
"{\"online\":false,\"at\":\"$old_at\",\"app\":\"verify-phase9\"}"
offline_line="$(wait_alert offline "$offline_base")" \
|| die "no delivered offline alert within ${WAIT_SECONDS}s"
echo " offline alert: $offline_line"
# ---- 6. notify ------------------------------------------------------------
echo
echo "== POST /notify"
notify_base="$(latest_alert_id notify)"; notify_base="${notify_base:-0}"
code="$(hub -o "$WORK/notify.json" -w '%{http_code}' -X POST \
-d '{"from":"internet_use_control","level":"important","message":"Phase 9 verification: notify path reached Sam.","person":"sam"}' \
"$SERVICE_URL/notify")"
[[ "$code" == "200" ]] || die "notify returned HTTP $code: $(cat "$WORK/notify.json")"
cat "$WORK/notify.json"; echo
notify_line="$(wait_alert notify "$notify_base")" \
|| die "no delivered notify alert within ${WAIT_SECONDS}s"
echo " notify alert: $notify_line"
echo
echo "PASS: Phase 9 — Hub answers, limit/offline alerts delivered, notify delivered"