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>
This commit is contained in:
203
deploy/verify-phase9.sh
Executable file
203
deploy/verify-phase9.sh
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user