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>
204 lines
8.8 KiB
Bash
Executable File
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"
|