diff --git a/.gitignore b/.gitignore
index 5688e0e..8803763 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,6 @@ deploy/mosquitto/clients.env
# Pi agent generated memory (local state, not source)
.pi/memory-vault/
+
+# Backups of the real env file (mode 600, secrets).
+environment.d/*.bak*
diff --git a/Makefile b/Makefile
index bdb7d5b..673e618 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
-IMAGE ?= webrestrict/control:0.6.0-phase8
-BUILD_VERSION ?= 0.6.0-phase8
+IMAGE ?= webrestrict/control:0.7.0-phase9
+BUILD_VERSION ?= 0.7.0-phase9
# Bounded docker helper for the Go toolchain. The container runs as the current
# user, so the repository and the module cache stay owned by you.
@@ -29,6 +29,7 @@ help:
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
@echo "make verify-phase8 prove an API lock reaches a device in under a second"
+ @echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver"
test:
$(GO_RUN) go test ./...
@@ -82,3 +83,9 @@ mqtt-verify: mqtt-config
# broker subscriber receives the retained config. Never touches the 1883 plane.
verify-phase8:
deploy/verify-phase8.sh
+
+# Phase 9 definition of done: /capabilities, /ask, the alert engine and /notify
+# against the deployed service, with Apprise delivery checked in the alerts
+# table. Never touches the 1883 plane.
+verify-phase9:
+ deploy/verify-phase9.sh
diff --git a/README.md b/README.md
index 04e451a..369848b 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes
device config.
-This repository currently contains **Phase 8 — Overrides** (AGENTS.md §11), on
-top of Phases 1–7:
+This repository currently contains **Phase 9 — Hub and alerts** (AGENTS.md §11),
+on top of Phases 1–8:
- Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned
@@ -44,9 +44,17 @@ top of Phases 1–7:
shared device keycode (one code, every device) that a kid's client can redeem
for a short exemption. Both are recorded in `overrides` and `audit`, and a
state change is published retained before the API responds.
+- **Hub API**: the project side of the frozen contract
+ (`central_ai_llm_master/docs/ask-contract.md`) — `GET /capabilities`,
+ read-only `POST /ask`, and `POST /notify` — all behind the Hub bearer token.
+ `/ask` answers from SQLite and writes nothing.
+- **Alerts**: a background engine tells Sam through the house Apprise API when
+ a device reaches its limit (a blocking window turns on, or a manual lock is
+ applied) or has been offline too long. Every alert is recorded in `alerts`
+ and the audit log, with its delivery outcome.
-Enforcement itself (the kids' clients) and the Hub arrive in later phases.
-Nothing here touches the router, Pi-hole, or DNS.
+Enforcement itself (the kids' clients) arrives in later phases. Nothing here
+touches the router, Pi-hole, or DNS.
## Quick start (on `.13`)
@@ -62,7 +70,7 @@ docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
-# {"status":"ok","service":"control-service","version":"0.6.0-phase8",...}
+# {"status":"ok","service":"control-service","version":"0.7.0-phase9",...}
```
Open in a browser. It redirects to `/login`. Sign in
@@ -97,11 +105,19 @@ reads only the process environment.
| `CONTROL_MQTT_PASSWORD` | — | its password |
| `CONTROL_MQTT_CA_FILE` | `/etc/webrestrict/tls/ca.crt` | PEM bundle that verifies the broker (TLS only) |
| `CONTROL_MQTT_INSECURE` | `false` | Allow a plaintext, credential-less broker. Only for a broker with no TLS listener |
+| `CONTROL_HUB_TOKEN` | — | Bearer token the Hub sends to `/capabilities`, `/ask` and `/notify` (Phase 9); empty disables them with 503 |
+| `CONTROL_APPRISE_URL` | `http://192.168.20.35:8210` | House Apprise API that alerts and notifications are delivered through |
+| `CONTROL_APPRISE_KEY` | — | Stored Apprise configuration key (option A) |
+| `CONTROL_APPRISE_URLS` | — | Stateless Apprise destination list (option B, lab default) |
+| `CONTROL_APPRISE_TOKEN` | — | Optional bearer token for a protected Apprise API |
+| `CONTROL_ALERTS_ENABLED` | `true` | Turn the limit/offline alert engine on or off |
+| `CONTROL_ALERT_OFFLINE_AFTER` | `30m` | How long a device may be unreachable before Sam is told |
+| `CONTROL_ALERT_INTERVAL` | `30s` | How often the alert engine re-evaluates device state |
Generate a bcrypt hash without host Go:
```bash
-docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 \
+docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 \
hash-password 'your-password'
```
@@ -184,7 +200,7 @@ List the MQTT identity of every device (tokens, no secrets):
docker exec webrestrict-control /app/control mqtt-clients
```
-## Endpoints (Phase 2–7)
+## Endpoints (Phase 2–9)
| Method | Path | Notes |
|---|---|---|
@@ -240,6 +256,72 @@ Every form POST needs the `csrf` field from the session cookie. State changes
append to the `audit` table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10).
+## Hub API (Phase 9)
+
+The project side of the frozen contract
+(`central_ai_llm_master/docs/ask-contract.md`). All three endpoints require
+`Authorization: Bearer $CONTROL_HUB_TOKEN`, a shared token separate from the
+dashboard session and the admin API. With no token set they answer `503`.
+
+| Method | Path | Notes |
+|---|---|---|
+| `GET` | `/capabilities` | `{"project":"internet_use_control","topics":[…],"people":[…]}`. The Hub reads it to route a question. |
+| `POST` | `/ask` | Read-only. Body is the contract's `AskRequest`; the reply is always `200` with an `answer` or `answer:null` plus a `reason`. |
+| `POST` | `/notify` | Accepts the contract's `{"from","level","message","person"}` and delivers it to Sam through Apprise. |
+
+`/ask` answers these intents from SQLite, deterministically and without an LLM:
+
+```bash
+curl -sS -H "Authorization: Bearer $CONTROL_HUB_TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"question":"how long have the kids been on screens today?","asker":"sam"}' \
+ http://127.0.0.1:8791/ask
+# {"answer":"The kids have had 1h 22m of screen time today.",
+# "data":{"day":"2026-10-13","total_seconds":4925,
+# "by_device":[{"device":"Harry's phone","child":"Harry",
+# "seconds":4925,"app_seconds":4925,"domain_seconds":4800}]},
+# "expires":"2026-10-13T05:52:33Z"}
+```
+
+- **Screen time**: a device-day is measured by its foreground-app total; the
+domain total is used only when a device reports no apps at all, so a report
+that carries both is not double-counted.
+- **What is blocked**: the currently blocked devices and until when.
+- **Presence**: which devices are online, and for how long an offline one has
+been down.
+- **One person at most**: the `person` field (or a child named in the question)
+scopes the answer; only `sam` may ask.
+
+`/ask` is **read-only** (AGENTS.md §10 rule 9). It calls only store read methods
+and writes nothing, including no audit row; `TestAskWritesNothing` asserts it.
+
+## Alerts (Phase 9)
+
+A background engine (`internal/alert`) evaluates device state every
+`CONTROL_ALERT_INTERVAL` and raises one alert per transition:
+
+- **Limit hit** — a device became blocked (a weekly window turned on, or a
+ manual lock was applied), or
+- **Offline too long** — presence is false and the last report is older than
+ `CONTROL_ALERT_OFFLINE_AFTER`. A device that has never reported is not
+ alerted.
+
+The alert is recorded in `alerts` **before** delivery, then sent through
+Apprise. The `delivered` flag and any error are written back, so a delivery
+failure is visible rather than silent. The dedup state in `alert_state` makes an
+alert fire once per transition, and a restart seeds that state without
+replaying an old limit.
+
+`POST /notify` uses the same Apprise path for on-demand messages (a tamper
+report from a phone client, FR-17, will use it).
+
+Operational views of the same data:
+
+```bash
+docker exec webrestrict-control /app/control alerts --limit 10
+docker exec webrestrict-control /app/control usage --day 2026-10-13 --detail
+```
+
## Telemetry and usage (Phase 7)
Usage arrives on `restrict//telemetry` and is stored in `telemetry`:
@@ -353,6 +435,22 @@ It needs the `control-service` broker credentials
(`deploy/mosquitto/clients.env`) and a device that exists. It never restarts or
reconfigures anything shared.
+### Prove the Hub answers and the alerts deliver
+
+The Phase 9 definition of done. `make verify-phase9` calls `/capabilities` and
+`/ask`, checks the answer against an independent count from the telemetry detail
+table, triggers a limit alert and an offline-too-long alert, and posts a
+notification:
+
+```bash
+make verify-phase9
+```
+
+Each alert is checked in the `alerts` table for `delivered`; a recorded but
+undelivered alert fails the script with the delivery error shown. It needs
+`CONTROL_HUB_TOKEN`, `CONTROL_ADMIN_API_TOKEN`, a working Apprise destination
+and the device broker credentials. It leaves the device unlocked and online.
+
## Development
There is no host Go toolchain on `.13`; use the cached builder image.
@@ -365,6 +463,7 @@ make up # docker compose up -d --build
make logs # follow container logs
make mqtt-verify # prove TLS + credentials + ACLs on a throwaway broker
make verify-phase8 # prove an API lock reaches a device in under a second
+make verify-phase9 # prove the Hub answers and the Apprise alerts deliver
```
`make test` and `make vet` run the container as your own user and keep the module
@@ -375,22 +474,27 @@ broker on `127.0.0.1:18883` and removes it afterwards.
## Layout
```
-cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode)
+cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock + grant expiry
schedules.go weekly windows and the effective state (incl. grants)
reports.go presence, telemetry, the daily rollup, top lists, audit cap
+ alerts.go alert records and per-device dedup state
lists.go allow/block lists and device lookup by topic
overrides.go override history + the shared device keycode
- migrations/001_init.sql … 004_overrides_grant.sql
+ migrations/001_init.sql … 005_alerts.sql
internal/auth/ signed-cookie session login + CSRF
+internal/apprise/ Apprise API client (alerts and notifications)
+internal/alert/ the limit/offline alert engine (transition -> Apprise)
+internal/hub/ the Hub contract: capabilities, read-only answers, notify
internal/mqtt/ the broker link: retained config out, reports in
payload.go topic and payload contract for the kids' clients
link.go paho client, subscriptions, publish, TLS verification
internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers
admin_api.go versioned admin API + the device keycode endpoint
+ hub_api.go /capabilities, /ask (read-only), /notify
schedules.go weekly schedule and block/allow editors
usage.go usage screen: week grid, top apps, top sites
templates/ login, dashboard, device form, editors, usage, partials
@@ -400,6 +504,8 @@ docker-compose.yml own container, port 8791
environment.d/ control-service.env.example (real file gitignored)
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
+deploy/verify-phase8.sh lock latency proof
+deploy/verify-phase9.sh Hub + alerts proof
```
## Caddy
@@ -408,11 +514,16 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
`webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35`
(sam-Ubuntu1); see that file for the two proxy patterns and the apply commands.
-## Non-goals (Phase 8)
+## Non-goals (Phase 9)
- No grant button in the dashboard. Grant is on the admin API; the dashboard has
lock and unlock. Adding the button is a later UI change.
- No per-child or per-device keycode. One code, every device (§10 rule 3).
- No HTTP telemetry ingest and no kids' clients (Phases 4–5). The topic and
payload contract above is what they implement.
-- No Hub contact (Phase 9).
+- No Alerts screen in the dashboard yet. The alert history is in `alerts` and is
+ readable with `control alerts`; the screen is a later UI change.
+- The Hub is not wired to this service yet. `/ask` is proven directly with the
+ exact Hub request shape (`make verify-phase9`); registering the project with
+ the running Hub (its `HUB_PROJECTS` and a project token) and restarting the
+ `hub` container is a host change pending approval.
diff --git a/cmd/control/main.go b/cmd/control/main.go
index 8136b6a..088a055 100644
--- a/cmd/control/main.go
+++ b/cmd/control/main.go
@@ -16,14 +16,17 @@ import (
"net/http"
"os"
"os/signal"
+ "strconv"
"strings"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/alert"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/mqtt"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web"
@@ -66,6 +69,12 @@ func main() {
case "rollup":
rollup(os.Args[2:])
return
+ case "alerts":
+ alertsCommand(os.Args[2:])
+ return
+ case "usage":
+ usageCommand(os.Args[2:])
+ return
case "version":
fmt.Println(version)
return
@@ -178,6 +187,22 @@ func run() error {
return err
}
+ // Phase 9: the alert engine turns a limit hit or a too-long offline spell
+ // into one Apprise message to Sam (FR-13). It is optional; /ask and
+ // /notify keep working without it.
+ if cfg.AlertsEnabled {
+ engine := alert.New(st, srv.Apprise(), log, cfg.AlertOfflineAfter, cfg.ScheduleLocation)
+ engine.Interval = cfg.AlertInterval
+ go engine.Run(ctx)
+ if !cfg.AppriseConfigured() {
+ log.Warn("alerts are enabled but Apprise is not configured; alerts will be recorded as undelivered")
+ } else {
+ log.Info("alert engine started", "offline_after", cfg.AlertOfflineAfter.String())
+ }
+ } else {
+ log.Info("alert engine disabled")
+ }
+
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
@@ -305,6 +330,122 @@ func runMaintenance(ctx context.Context, log *slog.Logger, st *store.Store) {
}
}
+// alertsCommand prints the most recent alerts, newest first, one per line as
+// tab-separated fields:
+//
+// \t\t\t\t\t
+//
+// It is a read-only operational view. The Phase 9 verification script uses it
+// to check that an alert was raised and delivered.
+func alertsCommand(args []string) {
+ dbPath := env("CONTROL_DB_PATH", "/data/control.db")
+ limit := 20
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--db", "-db":
+ if i+1 < len(args) {
+ dbPath = args[i+1]
+ i++
+ }
+ case "--limit", "-limit":
+ if i+1 < len(args) {
+ if n, err := strconv.Atoi(args[i+1]); err == nil {
+ limit = n
+ }
+ i++
+ }
+ }
+ }
+
+ st, err := store.Open(dbPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+ defer st.Close()
+ if err := st.Migrate(context.Background()); err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+ list, err := st.ListAlerts(context.Background(), limit)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+ for _, a := range list {
+ delivered := "pending"
+ if a.Delivered {
+ delivered = "delivered"
+ }
+ device := a.DeviceName
+ if device == "" {
+ device = "-"
+ }
+ fmt.Printf("%d\t%s\t%s\t%s\t%s\t%s\n",
+ a.ID, a.Kind, delivered, a.CreatedAt.Format(time.RFC3339), device, a.Message)
+ }
+}
+
+// usageCommand prints one day's per-device screen time. --detail reads the raw
+// telemetry detail table instead of the derived rollup, so an operator can
+// check the daily answer independently.
+func usageCommand(args []string) {
+ dbPath := env("CONTROL_DB_PATH", "/data/control.db")
+ day := time.Now().Format("2006-01-02")
+ detail := false
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--db", "-db":
+ if i+1 < len(args) {
+ dbPath = args[i+1]
+ i++
+ }
+ case "--day", "-day":
+ if i+1 < len(args) {
+ day = args[i+1]
+ i++
+ }
+ case "--detail", "-detail":
+ detail = true
+ }
+ }
+
+ st, err := store.Open(dbPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+ defer st.Close()
+ ctx := context.Background()
+ if err := st.Migrate(ctx); err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+
+ source := "rollup"
+ var rows []store.DeviceDayUsage
+ if detail {
+ source = "detail"
+ rows, err = st.DetailUsageByDeviceForDay(ctx, day)
+ } else {
+ rows, err = st.UsageByDeviceForDay(ctx, day)
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "control:", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("day=%s source=%s\n", day, source)
+ total := 0
+ for _, u := range rows {
+ screen := hub.ScreenSeconds(u.AppSeconds, u.DomainSeconds)
+ total += screen
+ fmt.Printf("%s\t%s\tapp=%d\tdomain=%d\tscreen=%d\n",
+ u.Name, u.Child, u.AppSeconds, u.DomainSeconds, screen)
+ }
+ fmt.Printf("total_screen=%d\n", total)
+}
+
// rollup rebuilds the daily usage rollup from the telemetry detail table. It is
// idempotent and safe to run while the service is up; it exits non-zero on a
// failure so a script can tell.
diff --git a/deploy/verify-phase9.sh b/deploy/verify-phase9.sh
new file mode 100755
index 0000000..00dbf11
--- /dev/null
+++ b/deploy/verify-phase9.sh
@@ -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 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 : 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
+ 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"
diff --git a/docker-compose.yml b/docker-compose.yml
index d7cd123..7aad882 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,8 +12,8 @@ services:
build:
context: .
args:
- VERSION: "0.6.0-phase8"
- image: webrestrict/control:0.6.0-phase8
+ VERSION: "0.7.0-phase9"
+ image: webrestrict/control:0.7.0-phase9
container_name: webrestrict-control
restart: unless-stopped
env_file:
diff --git a/environment.d/control-service.env.example b/environment.d/control-service.env.example
index a3b8291..fd406ff 100644
--- a/environment.d/control-service.env.example
+++ b/environment.d/control-service.env.example
@@ -38,7 +38,7 @@ CONTROL_ADMIN_USERNAME=sam
CONTROL_ADMIN_PASSWORD=change-me
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty.
-# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 hash-password 'your-password'
+# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 hash-password 'your-password'
# CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...
# --- Admin API (Phase 8) ---------------------------------------------------
@@ -51,7 +51,7 @@ CONTROL_ADMIN_API_TOKEN=change-me-to-a-long-random-token
# The shared device keycode (one code, every device — AGENTS.md §10 rule 3).
# Give a bcrypt hash, never the code itself. Generate one with:
-# docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 \
+# docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 \
# hash-password 'the-code'
# The hash is seeded into SQLite at start-up and can be changed later with
# `docker exec webrestrict-control /app/control keycode`.
@@ -87,3 +87,35 @@ CONTROL_MQTT_CA_FILE=/etc/webrestrict/tls/ca.crt
# unless this is true, and it logs a warning when it is. Leave it false against
# the shared Mosquitto (AGENTS.md §10 rule 6).
CONTROL_MQTT_INSECURE=false
+
+# --- Hub API (Phase 9) -----------------------------------------------------
+# The project side of central_ai_llm_master/docs/ask-contract.md. The Hub sends
+# this bearer token; /capabilities and /ask refuse an unsigned request. When it
+# is empty the three endpoints answer 503 and nothing else changes.
+# Generate: openssl rand -hex 32
+CONTROL_HUB_TOKEN=change-me-to-a-long-random-token
+
+# --- Alerts (Phase 9) ------------------------------------------------------
+# Apprise: the house notification gateway on `.35` (AGENTS.md §8). Alerts and
+# accepted notifications are delivered through it (FR-13).
+CONTROL_APPRISE_URL=http://192.168.20.35:8210
+
+# Option A: a stored Apprise configuration key.
+# CONTROL_APPRISE_KEY=sam_email
+
+# Option B (lab default): the stateless destination list. This is the same
+# shared value as APPRISE_URLS in ~/.config/environment.d/10-secrets.conf.
+# CONTROL_APPRISE_URLS='mailto://user:app-password@gmail.com'
+
+# Optional bearer token if the Apprise API is protected. Empty in this lab.
+# CONTROL_APPRISE_TOKEN=
+
+# Turn the limit/offline alert engine on. MQTT-style: with it off the service
+# still answers /ask and /notify.
+CONTROL_ALERTS_ENABLED=true
+
+# How long a device may be unreachable before Sam is told. Go duration.
+CONTROL_ALERT_OFFLINE_AFTER=30m
+
+# How often the alert engine re-evaluates device state. Go duration.
+CONTROL_ALERT_INTERVAL=30s
diff --git a/internal/alert/alert.go b/internal/alert/alert.go
new file mode 100644
index 0000000..824a340
--- /dev/null
+++ b/internal/alert/alert.go
@@ -0,0 +1,263 @@
+// Package alert watches device state and tells Sam when something needs his
+// attention (AGENTS.md §4 FR-13, §5 screen 6, §11 Phase 9).
+//
+// Two conditions raise an alert:
+//
+// - a limit is hit: a device becomes blocked (a weekly window turned on, or a
+// manual lock was applied), and
+// - a device is offline too long: presence is false and the last report is
+// older than the configured threshold.
+//
+// Alerts fire once per transition, not once per poll. The last observed
+// condition is kept in `alert_state`; the first time the engine sees a device it
+// seeds that state without alerting, so a restart never replays an old limit.
+//
+// Delivery goes through Apprise. The `alerts` row is written before delivery, so
+// a delivery failure leaves a visible, auditable record rather than silence.
+package alert
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+// DefaultOfflineAfter is how long a device may be unreachable before Sam is
+// told. Overridable with CONTROL_ALERT_OFFLINE_AFTER.
+const DefaultOfflineAfter = 30 * time.Minute
+
+// DefaultInterval is how often the engine re-evaluates. It bounds how late an
+// alert can be, and it is short enough that a limit hit feels immediate.
+const DefaultInterval = 30 * time.Second
+
+// Store is the data surface the engine needs. *store.Store satisfies it.
+type Store interface {
+ ListDevices(ctx context.Context) ([]store.Device, error)
+ EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
+ AlertStates(ctx context.Context) (map[int64]store.AlertState, error)
+ SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error
+ CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*store.Alert, error)
+ MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error
+}
+
+// Notifier delivers one alert. *apprise.Client satisfies it; a nil Notifier is
+// not configured and the engine records the alert as undelivered.
+type Notifier interface {
+ Configured() bool
+ SendType(ctx context.Context, title, body, notifyType string) error
+}
+
+// Engine evaluates device state on an interval and raises transition alerts.
+type Engine struct {
+ Store Store
+ Notifier Notifier
+ Log *slog.Logger
+ OfflineAfter time.Duration
+ Interval time.Duration
+ Location *time.Location
+ // Now is injectable so tests are deterministic.
+ Now func() time.Time
+}
+
+// New builds an engine. A zero offlineAfter uses DefaultOfflineAfter; a zero
+// interval uses DefaultInterval.
+func New(st Store, notifier Notifier, log *slog.Logger, offlineAfter time.Duration, loc *time.Location) *Engine {
+ if offlineAfter <= 0 {
+ offlineAfter = DefaultOfflineAfter
+ }
+ if log == nil {
+ log = slog.Default()
+ }
+ if loc == nil {
+ loc = time.Local
+ }
+ return &Engine{
+ Store: st,
+ Notifier: notifier,
+ Log: log,
+ OfflineAfter: offlineAfter,
+ Interval: DefaultInterval,
+ Location: loc,
+ Now: func() time.Time { return time.Now().In(loc) },
+ }
+}
+
+// Run evaluates once at start-up (to seed state without alerting) and then on
+// every tick until ctx is cancelled.
+func (e *Engine) Run(ctx context.Context) {
+ interval := e.Interval
+ if interval <= 0 {
+ interval = DefaultInterval
+ }
+ e.Evaluate(ctx)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ e.Evaluate(ctx)
+ }
+ }
+}
+
+// Evaluate checks every device once and raises an alert for each condition that
+// just became true.
+func (e *Engine) Evaluate(ctx context.Context) {
+ now := e.now()
+ devs, err := e.Store.ListDevices(ctx)
+ if err != nil {
+ e.Log.Error("alert evaluate: list devices", "err", err)
+ return
+ }
+ states, err := e.Store.AlertStates(ctx)
+ if err != nil {
+ e.Log.Error("alert evaluate: alert states", "err", err)
+ return
+ }
+
+ for _, dev := range devs {
+ eff, err := e.Store.EffectiveState(ctx, dev, now)
+ if err != nil {
+ e.Log.Error("alert evaluate: effective state", "device", dev.Name, "err", err)
+ continue
+ }
+ locked := eff.Locked
+ offline := e.offlineTooLong(dev, now)
+
+ prev, known := states[dev.ID]
+ if !known {
+ // First observation: remember the condition, alert on nothing.
+ if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
+ e.Log.Error("alert seed state", "device", dev.Name, "err", err)
+ }
+ continue
+ }
+
+ if locked && !prev.Locked {
+ e.fire(ctx, dev, store.AlertLimit, limitMessage(dev, eff, e.loc()))
+ }
+ if offline && !prev.Offline {
+ e.fire(ctx, dev, store.AlertOffline, offlineMessage(dev, now))
+ }
+ if locked != prev.Locked || offline != prev.Offline {
+ if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
+ e.Log.Error("alert save state", "device", dev.Name, "err", err)
+ }
+ }
+ }
+}
+
+// fire records an alert and delivers it. A delivery failure is recorded on the
+// row and logged; it is never retried here, so a broken Apprise cannot spin.
+func (e *Engine) fire(ctx context.Context, dev store.Device, kind, message string) {
+ deviceID := dev.ID
+ alert, err := e.Store.CreateAlert(ctx, &deviceID, kind, message)
+ if err != nil {
+ e.Log.Error("alert create", "device", dev.Name, "kind", kind, "err", err)
+ return
+ }
+
+ if e.Notifier == nil || !e.Notifier.Configured() {
+ if err := e.Store.MarkAlertDelivered(ctx, alert.ID, "apprise is not configured"); err != nil {
+ e.Log.Error("alert record", "id", alert.ID, "err", err)
+ }
+ e.Log.Warn("alert not delivered: apprise is not configured",
+ "device", dev.Name, "kind", kind, "message", message)
+ return
+ }
+
+ title := "Internet Use Control — " + alertTitle(kind)
+ dctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ if err := e.Notifier.SendType(dctx, title, message, apprise.TypeWarning); err != nil {
+ if err := e.Store.MarkAlertDelivered(ctx, alert.ID, err.Error()); err != nil {
+ e.Log.Error("alert record", "id", alert.ID, "err", err)
+ }
+ e.Log.Error("alert delivery failed", "device", dev.Name, "kind", kind, "err", err)
+ return
+ }
+ if err := e.Store.MarkAlertDelivered(ctx, alert.ID, ""); err != nil {
+ e.Log.Error("alert record", "id", alert.ID, "err", err)
+ }
+ e.Log.Info("alert delivered", "device", dev.Name, "kind", kind, "alert_id", alert.ID)
+}
+
+// offlineTooLong reports whether a device has been unreachable past the
+// threshold. A device that has never reported is never "offline too long": a
+// freshly registered device is not an alert.
+func (e *Engine) offlineTooLong(dev store.Device, now time.Time) bool {
+ if dev.Online || dev.LastSeen == nil {
+ return false
+ }
+ return now.Sub(*dev.LastSeen) > e.OfflineAfter
+}
+
+func (e *Engine) now() time.Time {
+ if e.Now == nil {
+ return time.Now().In(e.loc())
+ }
+ return e.Now()
+}
+
+func (e *Engine) loc() *time.Location {
+ if e.Location == nil {
+ return time.Local
+ }
+ return e.Location
+}
+
+// limitMessage is the one sentence Sam receives when a device is blocked.
+func limitMessage(dev store.Device, eff store.EffectiveState, loc *time.Location) string {
+ reason := "its schedule"
+ if eff.Reason == "manual" {
+ reason = "a manual lock"
+ }
+ if eff.Until != nil {
+ return fmt.Sprintf("%s reached its limit and is blocked by %s (until %s).",
+ dev.Name, reason, eff.Until.In(loc).Format("15:04"))
+ }
+ return fmt.Sprintf("%s reached its limit and is blocked by %s.", dev.Name, reason)
+}
+
+// offlineMessage is the one sentence Sam receives when a device is unreachable.
+func offlineMessage(dev store.Device, now time.Time) string {
+ if dev.LastSeen == nil {
+ return fmt.Sprintf("%s has been offline for a while.", dev.Name)
+ }
+ return fmt.Sprintf("%s has been offline for %s.", dev.Name, humanDuration(now.Sub(*dev.LastSeen)))
+}
+
+func alertTitle(kind string) string {
+ switch kind {
+ case store.AlertLimit:
+ return "limit hit"
+ case store.AlertOffline:
+ return "device offline"
+ default:
+ return "notification"
+ }
+}
+
+// humanDuration renders a duration compactly: "2h 10m", "45m", "under a minute".
+func humanDuration(d time.Duration) string {
+ seconds := int(d.Seconds())
+ if seconds <= 0 {
+ return "0m"
+ }
+ if seconds < 60 {
+ return "under a minute"
+ }
+ m := seconds / 60
+ h := m / 60
+ m %= 60
+ if h == 0 {
+ return fmt.Sprintf("%dm", m)
+ }
+ return fmt.Sprintf("%dh %02dm", h, m)
+}
diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go
new file mode 100644
index 0000000..8372618
--- /dev/null
+++ b/internal/alert/alert_test.go
@@ -0,0 +1,178 @@
+package alert
+
+import (
+ "context"
+ "log/slog"
+ "testing"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+type fakeStore struct {
+ devices []store.Device
+ eff map[int64]store.EffectiveState
+ states map[int64]store.AlertState
+ alerts []*store.Alert
+ nextID int64
+}
+
+func newFakeStore(devices ...store.Device) *fakeStore {
+ return &fakeStore{
+ devices: devices,
+ eff: map[int64]store.EffectiveState{},
+ states: map[int64]store.AlertState{},
+ }
+}
+
+func (f *fakeStore) ListDevices(context.Context) ([]store.Device, error) { return f.devices, nil }
+
+func (f *fakeStore) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) {
+ return f.eff[dev.ID], nil
+}
+
+func (f *fakeStore) AlertStates(context.Context) (map[int64]store.AlertState, error) {
+ out := make(map[int64]store.AlertState, len(f.states))
+ for k, v := range f.states {
+ out[k] = v
+ }
+ return out, nil
+}
+
+func (f *fakeStore) SetAlertState(_ context.Context, deviceID int64, locked, offline bool) error {
+ f.states[deviceID] = store.AlertState{Locked: locked, Offline: offline}
+ return nil
+}
+
+func (f *fakeStore) CreateAlert(_ context.Context, deviceID *int64, kind, message string) (*store.Alert, error) {
+ f.nextID++
+ a := &store.Alert{ID: f.nextID, DeviceID: deviceID, Kind: kind, Message: message}
+ f.alerts = append(f.alerts, a)
+ return a, nil
+}
+
+func (f *fakeStore) MarkAlertDelivered(_ context.Context, id int64, errMsg string) error {
+ for _, a := range f.alerts {
+ if a.ID == id {
+ a.Delivered = errMsg == ""
+ a.Error = errMsg
+ }
+ }
+ return nil
+}
+
+type fakeNotifier struct {
+ configured bool
+ sent []string
+ err error
+}
+
+func (f *fakeNotifier) Configured() bool { return f.configured }
+
+func (f *fakeNotifier) SendType(_ context.Context, _, body, _ string) error {
+ if f.err != nil {
+ return f.err
+ }
+ f.sent = append(f.sent, body)
+ return nil
+}
+
+func newEngine(st *fakeStore, n *fakeNotifier, now time.Time) *Engine {
+ e := New(st, n, slog.New(slog.DiscardHandler), 30*time.Minute, time.UTC)
+ e.Now = func() time.Time { return now }
+ return e
+}
+
+func TestSeedThenLimitTransition(t *testing.T) {
+ now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
+ until := now.Add(30 * time.Minute)
+ fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"})
+ fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: false, Reason: ""}
+ fn := &fakeNotifier{configured: true}
+ e := newEngine(fs, fn, now)
+
+ // First observation seeds the state and raises nothing.
+ e.Evaluate(context.Background())
+ if len(fs.alerts) != 0 {
+ t.Fatalf("seed raised %d alerts, want 0", len(fs.alerts))
+ }
+ if st := fs.states[1]; st.Locked {
+ t.Fatalf("seeded state = %#v, want unlocked", st)
+ }
+
+ // A schedule turns on: exactly one limit alert.
+ fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until}
+ e.Evaluate(context.Background())
+ if len(fs.alerts) != 1 {
+ t.Fatalf("alerts = %d, want 1", len(fs.alerts))
+ }
+ if fs.alerts[0].Kind != store.AlertLimit {
+ t.Errorf("kind = %q, want limit", fs.alerts[0].Kind)
+ }
+ if !fs.alerts[0].Delivered {
+ t.Error("alert was not marked delivered")
+ }
+ if len(fn.sent) != 1 {
+ t.Fatalf("notifier sent %d, want 1", len(fn.sent))
+ }
+
+ // No new alert while the condition stays true.
+ e.Evaluate(context.Background())
+ if len(fs.alerts) != 1 {
+ t.Fatalf("repeat evaluation raised %d alerts, want 1", len(fs.alerts))
+ }
+}
+
+func TestOfflineTransition(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ old := now.Add(-2 * time.Hour)
+ fs := newFakeStore(store.Device{ID: 2, Name: "Finn's computer", Child: "Finn", Online: true})
+ fs.eff[2] = store.EffectiveState{DeviceID: 2}
+ fn := &fakeNotifier{configured: true}
+ e := newEngine(fs, fn, now)
+ e.Evaluate(context.Background()) // seed online
+
+ fs.devices[0].Online = false
+ fs.devices[0].LastSeen = &old
+ e.Evaluate(context.Background())
+
+ if len(fs.alerts) != 1 || fs.alerts[0].Kind != store.AlertOffline {
+ t.Fatalf("alerts = %#v, want one offline alert", fs.alerts)
+ }
+ if len(fn.sent) != 1 {
+ t.Fatalf("notifier sent %d, want 1", len(fn.sent))
+ }
+}
+
+func TestNeverSeenDeviceIsNotOffline(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ fs := newFakeStore(store.Device{ID: 3, Name: "New device", Child: "Harry", Online: false})
+ fs.eff[3] = store.EffectiveState{DeviceID: 3}
+ e := newEngine(fs, &fakeNotifier{configured: true}, now)
+
+ e.Evaluate(context.Background()) // seed
+ e.Evaluate(context.Background()) // still no last_seen
+ if len(fs.alerts) != 0 {
+ t.Fatalf("alerts = %d, want 0 for a never-seen device", len(fs.alerts))
+ }
+}
+
+func TestUndeliveredWhenNotConfigured(t *testing.T) {
+ now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
+ until := now.Add(30 * time.Minute)
+ fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"})
+ fs.eff[1] = store.EffectiveState{DeviceID: 1}
+ fn := &fakeNotifier{configured: false}
+ e := newEngine(fs, fn, now)
+ e.Evaluate(context.Background())
+
+ fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until}
+ e.Evaluate(context.Background())
+
+ if len(fs.alerts) != 1 {
+ t.Fatalf("alerts = %d, want 1", len(fs.alerts))
+ }
+ if fs.alerts[0].Delivered {
+ t.Error("alert marked delivered with an unconfigured notifier")
+ }
+}
diff --git a/internal/apprise/apprise.go b/internal/apprise/apprise.go
new file mode 100644
index 0000000..323a7bf
--- /dev/null
+++ b/internal/apprise/apprise.go
@@ -0,0 +1,123 @@
+// Package apprise delivers notifications through the house Apprise API
+// (AGENTS.md §8, §11 Phase 9).
+//
+// Apprise is the shared notification gateway that already reaches Sam by
+// email. This client speaks the standard Apprise API server shape:
+//
+// POST {base}/notify/{key} JSON {"title","body","type"}
+// POST {base}/notify JSON {"urls","title","body","type"} (stateless)
+//
+// A key names a stored Apprise configuration. When no key is set the client
+// uses the stateless form with an explicit list of Apprise URLs, which is how
+// the lab is configured (APPRISE_URLS). Secrets come from environment.d and are
+// never committed.
+package apprise
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Notification classes understood by Apprise.
+const (
+ TypeInfo = "info"
+ TypeSuccess = "success"
+ TypeWarning = "warning"
+ TypeFailure = "failure"
+)
+
+// Client posts a notification to one Apprise API server.
+type Client struct {
+ // BaseURL is the Apprise API root, e.g. http://192.168.20.35:8210.
+ BaseURL string
+ // Key names a stored Apprise configuration. Empty means stateless mode.
+ Key string
+ // URLs is the stateless destination list, used when Key is empty.
+ URLs []string
+ // Token is sent as a bearer token when the Apprise API is protected.
+ Token string
+ HTTP *http.Client
+}
+
+// New builds a client. A zero timeout defaults to ten seconds.
+func New(baseURL, key string, urls []string, token string, timeout time.Duration) *Client {
+ if timeout <= 0 {
+ timeout = 10 * time.Second
+ }
+ return &Client{
+ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
+ Key: strings.TrimSpace(key),
+ URLs: urls,
+ Token: token,
+ HTTP: &http.Client{Timeout: timeout},
+ }
+}
+
+// Configured reports whether a delivery can be attempted. A nil client is not
+// configured, so callers never need to branch on nil.
+func (c *Client) Configured() bool {
+ if c == nil || strings.TrimSpace(c.BaseURL) == "" {
+ return false
+ }
+ return c.Key != "" || len(c.URLs) > 0
+}
+
+// notifyBody is the Apprise API request body. `type` selects the notification
+// class; alerts are warnings.
+type notifyBody struct {
+ URLs []string `json:"urls,omitempty"`
+ Title string `json:"title,omitempty"`
+ Body string `json:"body"`
+ Type string `json:"type,omitempty"`
+}
+
+// Send delivers one informational notification.
+func (c *Client) Send(ctx context.Context, title, body string) error {
+ return c.SendType(ctx, title, body, TypeInfo)
+}
+
+// SendType delivers one notification with an explicit Apprise class. A failure
+// is returned; it is never retried here, so a caller can record the outcome.
+func (c *Client) SendType(ctx context.Context, title, body, notifyType string) error {
+ if !c.Configured() {
+ return fmt.Errorf("apprise is not configured")
+ }
+ if notifyType == "" {
+ notifyType = TypeInfo
+ }
+ payload := notifyBody{Title: title, Body: body, Type: notifyType}
+ url := c.BaseURL + "/notify"
+ if c.Key != "" {
+ url += "/" + c.Key
+ } else {
+ payload.URLs = c.URLs
+ }
+
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("encode apprise request: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
+ if err != nil {
+ return fmt.Errorf("build apprise request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+ if c.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.Token)
+ }
+
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return fmt.Errorf("post apprise: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("post apprise: status %d", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/internal/apprise/apprise_test.go b/internal/apprise/apprise_test.go
new file mode 100644
index 0000000..8dfacf8
--- /dev/null
+++ b/internal/apprise/apprise_test.go
@@ -0,0 +1,103 @@
+package apprise
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestConfigured(t *testing.T) {
+ cases := []struct {
+ name string
+ c *Client
+ want bool
+ }{
+ {"nil", nil, false},
+ {"no base", New("", "key", nil, "", 0), false},
+ {"base no destination", New("http://x", "", nil, "", 0), false},
+ {"key mode", New("http://x", "key", nil, "", 0), true},
+ {"stateless mode", New("http://x", "", []string{"mailto:a@b"}, "", 0), true},
+ }
+ for _, tc := range cases {
+ if got := tc.c.Configured(); got != tc.want {
+ t.Errorf("%s: Configured() = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestSendKeyMode(t *testing.T) {
+ var gotPath string
+ var gotBody map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &gotBody)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "sam_email", nil, "", 0)
+ if err := c.Send(context.Background(), "title", "body"); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ if gotPath != "/notify/sam_email" {
+ t.Errorf("path = %q, want /notify/sam_email", gotPath)
+ }
+ if gotBody["title"] != "title" || gotBody["body"] != "body" {
+ t.Errorf("body = %#v", gotBody)
+ }
+ if _, ok := gotBody["urls"]; ok {
+ t.Errorf("key mode must not send urls: %#v", gotBody)
+ }
+}
+
+func TestSendStatelessModeWithAuthAndType(t *testing.T) {
+ var gotAuth, gotType string
+ var gotURLs []any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ var body map[string]any
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &body)
+ gotType, _ = body["type"].(string)
+ gotURLs, _ = body["urls"].([]any)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "", []string{"mailto:sam@example.com"}, "secret", 0)
+ if err := c.SendType(context.Background(), "t", "b", TypeWarning); err != nil {
+ t.Fatalf("SendType: %v", err)
+ }
+ if gotAuth != "Bearer secret" {
+ t.Errorf("auth = %q", gotAuth)
+ }
+ if gotType != TypeWarning {
+ t.Errorf("type = %q, want %q", gotType, TypeWarning)
+ }
+ if len(gotURLs) != 1 || gotURLs[0] != "mailto:sam@example.com" {
+ t.Errorf("urls = %#v", gotURLs)
+ }
+}
+
+func TestSendErrorStatus(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL, "key", nil, "", 0)
+ if err := c.Send(context.Background(), "t", "b"); err == nil {
+ t.Fatal("Send() error = nil, want a status error")
+ }
+}
+
+func TestSendNotConfigured(t *testing.T) {
+ c := New("", "", nil, "", 0)
+ if err := c.Send(context.Background(), "t", "b"); err == nil {
+ t.Fatal("Send() error = nil, want not-configured")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 800129f..a12920e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -73,6 +73,30 @@ type Config struct {
// only until the shared Mosquitto gets the TLS listener; never set it in
// production.
MQTTInsecure bool
+
+ // HubToken authenticates the read-only Hub API: GET /capabilities,
+ // POST /ask and POST /notify (Phase 9, FR-12). Secret; the Hub sends it.
+ // When it is empty those endpoints answer 503 and nothing else changes.
+ HubToken string
+ // AppriseURL is the Apprise API base, for example
+ // http://192.168.20.35:8210. Alerts are delivered through it (FR-13).
+ AppriseURL string
+ // AppriseKey names a stored Apprise configuration. Empty means stateless
+ // mode, which uses AppriseURLs.
+ AppriseKey string
+ // AppriseURLs are the stateless Apprise destinations (APPRISE_URLS).
+ AppriseURLs []string
+ // AppriseToken is an optional bearer token when the Apprise API is
+ // protected. It is empty in this lab.
+ AppriseToken string
+ // AlertsEnabled turns the limit/offline alert engine on. When false the
+ // service still answers /ask and /notify.
+ AlertsEnabled bool
+ // AlertOfflineAfter is how long a device may be unreachable before Sam is
+ // told. Only used when AlertsEnabled is true.
+ AlertOfflineAfter time.Duration
+ // AlertInterval is how often the alert engine re-evaluates.
+ AlertInterval time.Duration
}
// Load reads and validates the configuration.
@@ -94,6 +118,15 @@ func Load() (*Config, error) {
MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"),
MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"),
MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false),
+
+ HubToken: os.Getenv("CONTROL_HUB_TOKEN"),
+ AppriseURL: strings.TrimRight(env("CONTROL_APPRISE_URL", DefaultAppriseURL), "/"),
+ AppriseKey: strings.TrimSpace(os.Getenv("CONTROL_APPRISE_KEY")),
+ AppriseURLs: splitList(os.Getenv("CONTROL_APPRISE_URLS")),
+ AppriseToken: os.Getenv("CONTROL_APPRISE_TOKEN"),
+ AlertsEnabled: envBool("CONTROL_ALERTS_ENABLED", true),
+ AlertOfflineAfter: durationSetting("CONTROL_ALERT_OFFLINE_AFTER", DefaultAlertOfflineAfter),
+ AlertInterval: durationSetting("CONTROL_ALERT_INTERVAL", DefaultAlertInterval),
}
secret := os.Getenv("CONTROL_SESSION_SECRET")
@@ -143,6 +176,24 @@ const DefaultMQTTBroker = "tcp://host.docker.internal:1883"
// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
const DefaultTimezone = "Australia/Sydney"
+// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
+const DefaultAppriseURL = "http://192.168.20.35:8210"
+
+// DefaultAlertOfflineAfter is how long a device may be unreachable before Sam
+// is told (FR-13).
+const DefaultAlertOfflineAfter = 30 * time.Minute
+
+// DefaultAlertInterval is how often the alert engine re-evaluates device state.
+const DefaultAlertInterval = 30 * time.Second
+
+// HubConfigured reports whether the Hub API can serve authenticated requests.
+func (c *Config) HubConfigured() bool { return strings.TrimSpace(c.HubToken) != "" }
+
+// AppriseConfigured reports whether alerts can be delivered.
+func (c *Config) AppriseConfigured() bool {
+ return strings.TrimSpace(c.AppriseURL) != "" && (c.AppriseKey != "" || len(c.AppriseURLs) > 0)
+}
+
// MQTTTLS reports whether the broker URL asks for TLS.
func (c *Config) MQTTTLS() bool {
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
@@ -189,6 +240,35 @@ func envBool(key string, fallback bool) bool {
return b
}
+// durationSetting reads a Go duration from the environment, falling back when
+// it is empty or unparseable.
+func durationSetting(key string, fallback time.Duration) time.Duration {
+ v := strings.TrimSpace(os.Getenv(key))
+ if v == "" {
+ return fallback
+ }
+ d, err := time.ParseDuration(v)
+ if err != nil || d <= 0 {
+ return fallback
+ }
+ return d
+}
+
+// splitList parses a comma- or whitespace-separated list. It is used for the
+// stateless Apprise destination list (APPRISE_URLS), which contains spaces.
+func splitList(s string) []string {
+ fields := strings.FieldsFunc(s, func(r rune) bool {
+ return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
+ })
+ out := make([]string, 0, len(fields))
+ for _, f := range fields {
+ if f = strings.TrimSpace(f); f != "" {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c12f163..73d8432 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -129,3 +129,60 @@ func TestMQTTDefaultsAndGuards(t *testing.T) {
t.Error("MQTTEnabled = true, want false when CONTROL_MQTT_ENABLED=false")
}
}
+
+func TestPhase9HubAndAlertDefaults(t *testing.T) {
+ t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
+ t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
+ t.Setenv("CONTROL_MQTT_INSECURE", "true")
+ for _, k := range []string{
+ "CONTROL_HUB_TOKEN", "CONTROL_APPRISE_KEY", "CONTROL_APPRISE_URLS",
+ "CONTROL_APPRISE_TOKEN", "CONTROL_ALERTS_ENABLED", "CONTROL_ALERT_OFFLINE_AFTER",
+ "CONTROL_APPRISE_URL",
+ } {
+ t.Setenv(k, "")
+ }
+
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.HubConfigured() {
+ t.Error("HubConfigured = true with no CONTROL_HUB_TOKEN")
+ }
+ if cfg.AppriseURL != DefaultAppriseURL {
+ t.Errorf("AppriseURL = %q, want %q", cfg.AppriseURL, DefaultAppriseURL)
+ }
+ if cfg.AppriseConfigured() {
+ t.Error("AppriseConfigured = true with no destination")
+ }
+ if !cfg.AlertsEnabled {
+ t.Error("AlertsEnabled = false, want true by default")
+ }
+ if cfg.AlertOfflineAfter != DefaultAlertOfflineAfter {
+ t.Errorf("AlertOfflineAfter = %v, want %v", cfg.AlertOfflineAfter, DefaultAlertOfflineAfter)
+ }
+
+ t.Setenv("CONTROL_HUB_TOKEN", "hub-secret")
+ t.Setenv("CONTROL_APPRISE_URLS", "mailto:sam@example.com pushover://x/y")
+ t.Setenv("CONTROL_ALERT_OFFLINE_AFTER", "10m")
+ t.Setenv("CONTROL_ALERTS_ENABLED", "false")
+ cfg, err = Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if !cfg.HubConfigured() {
+ t.Error("HubConfigured = false with a token set")
+ }
+ if !cfg.AppriseConfigured() {
+ t.Error("AppriseConfigured = false with CONTROL_APPRISE_URLS set")
+ }
+ if len(cfg.AppriseURLs) != 2 {
+ t.Errorf("AppriseURLs = %#v, want 2 entries", cfg.AppriseURLs)
+ }
+ if cfg.AlertOfflineAfter != 10*time.Minute {
+ t.Errorf("AlertOfflineAfter = %v, want 10m", cfg.AlertOfflineAfter)
+ }
+ if cfg.AlertsEnabled {
+ t.Error("AlertsEnabled = true, want false")
+ }
+}
diff --git a/internal/hub/ask.go b/internal/hub/ask.go
new file mode 100644
index 0000000..5186400
--- /dev/null
+++ b/internal/hub/ask.go
@@ -0,0 +1,345 @@
+// Read-only question answering for the Hub (AGENTS.md §4 FR-12, §10 rule 9,
+// §11 Phase 9).
+//
+// The answerer decides intent from the question text and answers from SQLite.
+// It is not an LLM call: it is deterministic, answers in milliseconds, and
+// never guesses. An unanswerable question returns answer=null plus a reason.
+//
+// Reader is the read-only data surface. *store.Store satisfies it. Keeping it
+// an interface makes the read-only guarantee explicit: every method reads.
+package hub
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+// Reader is the read-only data surface the answerer uses.
+type Reader interface {
+ ListDevices(ctx context.Context) ([]store.Device, error)
+ EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
+ UsageByDeviceForDay(ctx context.Context, day string) ([]store.DeviceDayUsage, error)
+}
+
+// Answerer answers one question at a time from a read-only Reader.
+type Answerer struct {
+ Reader Reader
+ // Location is the schedule timezone, so "today" matches the day a device
+ // stamped its telemetry in. Defaults to the process local zone.
+ Location *time.Location
+ // Now is injectable so the answer is deterministic in tests.
+ Now func() time.Time
+}
+
+// NewAnswerer builds an Answerer over a read-only Reader.
+func NewAnswerer(r Reader, loc *time.Location) *Answerer {
+ if loc == nil {
+ loc = time.Local
+ }
+ return &Answerer{Reader: r, Location: loc, Now: func() time.Time { return time.Now().In(loc) }}
+}
+
+func (a *Answerer) now() time.Time {
+ if a.Now == nil {
+ return time.Now().In(a.loc())
+ }
+ return a.Now()
+}
+
+func (a *Answerer) loc() *time.Location {
+ if a.Location == nil {
+ return time.Local
+ }
+ return a.Location
+}
+
+// Answer returns a short, speakable answer for one question. It never writes
+// state.
+func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
+ now := a.now()
+
+ asker := strings.ToLower(strings.TrimSpace(req.Asker))
+ person := strings.ToLower(strings.TrimSpace(req.Person))
+ switch {
+ case asker == "":
+ return cannotAnswer("no asker was given")
+ case asker != "sam":
+ return cannotAnswer("I only answer for Sam")
+ }
+
+ q := normalizeQuestion(req.Question)
+ if q == "" {
+ return cannotAnswer("no question was given")
+ }
+
+ // Resolve who the question is about. The person field wins when it names a
+ // child; otherwise a child named in the question is used; otherwise every
+ // device is included.
+ child := ""
+ switch person {
+ case "", "sam", "kids", "children", "everyone", "both":
+ // aggregate (or derive from the question below)
+ case "harry", "finn":
+ child = person
+ default:
+ return cannotAnswer("no data for that person")
+ }
+ if child == "" {
+ switch {
+ case strings.Contains(q, "harry"):
+ child = "harry"
+ case strings.Contains(q, "finn"):
+ child = "finn"
+ }
+ }
+
+ devs, err := a.Reader.ListDevices(ctx)
+ if err != nil {
+ return cannotAnswer("the device list could not be read")
+ }
+ if child != "" && !knownChild(devs, child) {
+ return cannotAnswer("no data for that person")
+ }
+
+ switch {
+ case containsAny(q, "screen time", "screentime", "screen-time",
+ "on screens", "on screen", "screen", "how long", "spent", "usage", "used"):
+ return a.answerScreenTime(ctx, now, child, q)
+ case containsAny(q, "blocked", "block", "locked", "lock", "restricted", "restrict"):
+ return a.answerBlocked(ctx, now, child, devs)
+ case containsAny(q, "online", "offline", "connected", "reachable"):
+ return a.answerPresence(ctx, now, child, devs)
+ default:
+ return cannotAnswer("I can answer questions about screen time, what is blocked, and whether a device is online")
+ }
+}
+
+// answerScreenTime answers "how long have the kids been on screens today?" from
+// the stored daily rollup. The day is "today" in the schedule timezone, or
+// yesterday when the question says so.
+func (a *Answerer) answerScreenTime(ctx context.Context, now time.Time, child, q string) AskResponse {
+ day := now
+ if containsAny(q, "yesterday", "last night") {
+ day = now.AddDate(0, 0, -1)
+ }
+ dayStr := day.Format("2006-01-02")
+
+ usage, err := a.Reader.UsageByDeviceForDay(ctx, dayStr)
+ if err != nil {
+ return cannotAnswer("usage could not be read")
+ }
+
+ total := 0
+ byDevice := make([]map[string]any, 0, len(usage))
+ for _, u := range usage {
+ if child != "" && !strings.EqualFold(strings.TrimSpace(u.Child), child) {
+ continue
+ }
+ seconds := ScreenSeconds(u.AppSeconds, u.DomainSeconds)
+ total += seconds
+ byDevice = append(byDevice, map[string]any{
+ "device": u.Name,
+ "child": u.Child,
+ "seconds": seconds,
+ "app_seconds": u.AppSeconds,
+ "domain_seconds": u.DomainSeconds,
+ })
+ }
+
+ when := "today"
+ if strings.EqualFold(dayStr, now.AddDate(0, 0, -1).Format("2006-01-02")) {
+ when = "yesterday"
+ }
+ subject := "The kids"
+ verb := "have"
+ if child != "" {
+ subject = titleCase(child)
+ verb = "has"
+ }
+
+ answer := fmt.Sprintf("%s %s had no recorded screen time %s.", subject, verb, when)
+ if total > 0 {
+ answer = fmt.Sprintf("%s %s had %s of screen time %s.", subject, verb, humanDuration(total), when)
+ }
+ return AskResponse{
+ Answer: answerText(answer),
+ Data: map[string]any{"day": dayStr, "total_seconds": total, "by_device": byDevice},
+ Expires: expiresIn(now, 15*time.Minute),
+ }
+}
+
+// answerBlocked reports whether anything is blocked right now, and until when.
+func (a *Answerer) answerBlocked(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
+ blocked := make([]map[string]any, 0, len(devs))
+ for _, dev := range devs {
+ if !matchesChild(dev, child) {
+ continue
+ }
+ eff, err := a.Reader.EffectiveState(ctx, dev, now)
+ if err != nil {
+ return cannotAnswer("device state could not be read")
+ }
+ if !eff.Locked {
+ continue
+ }
+ item := map[string]any{
+ "device": dev.Name,
+ "child": dev.Child,
+ "reason": eff.Reason,
+ }
+ if eff.Until != nil {
+ item["until"] = eff.Until.UTC().Format(time.RFC3339)
+ item["until_local"] = eff.Until.In(a.loc()).Format("15:04")
+ }
+ blocked = append(blocked, item)
+ }
+
+ resp := AskResponse{
+ Data: map[string]any{"blocked": blocked, "count": len(blocked)},
+ Expires: expiresIn(now, 5*time.Minute),
+ }
+ switch len(blocked) {
+ case 0:
+ resp.Answer = answerText("Nothing is blocked right now.")
+ case 1:
+ name, _ := blocked[0]["device"].(string)
+ reason, _ := blocked[0]["reason"].(string)
+ if until, ok := blocked[0]["until_local"].(string); ok && until != "" {
+ resp.Answer = answerText(fmt.Sprintf("%s is blocked until %s (%s).", name, until, reason))
+ } else {
+ resp.Answer = answerText(fmt.Sprintf("%s is blocked (%s).", name, reason))
+ }
+ default:
+ resp.Answer = answerText(fmt.Sprintf("%d devices are blocked right now.", len(blocked)))
+ }
+ return resp
+}
+
+// answerPresence reports whether the devices are online. Presence comes from
+// the broker's Last Will and Testament (FR-6).
+func (a *Answerer) answerPresence(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
+ selected := make([]store.Device, 0, len(devs))
+ for _, dev := range devs {
+ if matchesChild(dev, child) {
+ selected = append(selected, dev)
+ }
+ }
+ if len(selected) == 0 {
+ return cannotAnswer("no devices are registered")
+ }
+
+ offline := make([]map[string]any, 0, len(selected))
+ online := 0
+ for _, dev := range selected {
+ if dev.Online {
+ online++
+ continue
+ }
+ item := map[string]any{"device": dev.Name, "child": dev.Child}
+ if dev.LastSeen != nil {
+ item["last_seen"] = dev.LastSeen.UTC().Format(time.RFC3339)
+ item["last_seen_local"] = dev.LastSeen.In(a.loc()).Format("15:04")
+ item["offline_for"] = humanDuration(int(now.Sub(*dev.LastSeen).Seconds()))
+ }
+ offline = append(offline, item)
+ }
+
+ resp := AskResponse{
+ Data: map[string]any{"online": online, "offline": offline, "total": len(selected)},
+ Expires: expiresIn(now, 5*time.Minute),
+ }
+ switch {
+ case len(offline) == 0:
+ if len(selected) == 1 {
+ resp.Answer = answerText(fmt.Sprintf("%s is online.", selected[0].Name))
+ } else {
+ resp.Answer = answerText(fmt.Sprintf("All %d devices are online.", len(selected)))
+ }
+ case len(offline) == 1:
+ name, _ := offline[0]["device"].(string)
+ dur, _ := offline[0]["offline_for"].(string)
+ resp.Answer = answerText(fmt.Sprintf("%s is offline (for %s).", name, dur))
+ default:
+ resp.Answer = answerText(fmt.Sprintf("%d devices are offline.", len(offline)))
+ }
+ return resp
+}
+
+// ScreenSeconds is the screen-time measure for one device-day. The
+// foreground-app total is the measure; domain seconds are a browsing
+// sub-measure and are only used when a device reported no app rows at all (the
+// phone's VPN filter, for example). A report that carries both is not
+// double-counted.
+func ScreenSeconds(app, domain int) int {
+ if app > 0 {
+ return app
+ }
+ return domain
+}
+
+// knownChild reports whether any registered device belongs to child.
+func knownChild(devs []store.Device, child string) bool {
+ for _, dev := range devs {
+ if strings.EqualFold(strings.TrimSpace(dev.Child), child) {
+ return true
+ }
+ }
+ return false
+}
+
+// matchesChild reports whether a device is in scope for a question. An empty
+// child means every device.
+func matchesChild(dev store.Device, child string) bool {
+ return child == "" || strings.EqualFold(strings.TrimSpace(dev.Child), child)
+}
+
+// normalizeQuestion lowercases and collapses whitespace so intent matching is
+// stable.
+func normalizeQuestion(q string) string {
+ return strings.Join(strings.Fields(strings.ToLower(q)), " ")
+}
+
+func containsAny(q string, subs ...string) bool {
+ for _, s := range subs {
+ if strings.Contains(q, s) {
+ return true
+ }
+ }
+ return false
+}
+
+// expiresIn formats an RFC3339 expiry used by data-bearing answers.
+func expiresIn(now time.Time, d time.Duration) string {
+ return now.UTC().Add(d).Format(time.RFC3339)
+}
+
+// titleCase upper-cases the first letter of a single-word name.
+func titleCase(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return s
+ }
+ r := []rune(s)
+ return strings.ToUpper(string(r[0])) + string(r[1:])
+}
+
+// humanDuration renders seconds compactly: "3h 20m", "45m", "under a minute".
+func humanDuration(seconds int) string {
+ if seconds <= 0 {
+ return "0m"
+ }
+ if seconds < 60 {
+ return "under a minute"
+ }
+ m := seconds / 60
+ h := m / 60
+ m %= 60
+ if h == 0 {
+ return fmt.Sprintf("%dm", m)
+ }
+ return fmt.Sprintf("%dh %02dm", h, m)
+}
diff --git a/internal/hub/ask_test.go b/internal/hub/ask_test.go
new file mode 100644
index 0000000..e80f63f
--- /dev/null
+++ b/internal/hub/ask_test.go
@@ -0,0 +1,211 @@
+package hub
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+// fakeReader is a read-only Reader backed by fixed data.
+type fakeReader struct {
+ devices []store.Device
+ eff map[int64]store.EffectiveState
+ usage map[string][]store.DeviceDayUsage
+}
+
+func (f *fakeReader) ListDevices(context.Context) ([]store.Device, error) {
+ return f.devices, nil
+}
+
+func (f *fakeReader) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) {
+ return f.eff[dev.ID], nil
+}
+
+func (f *fakeReader) UsageByDeviceForDay(_ context.Context, day string) ([]store.DeviceDayUsage, error) {
+ return f.usage[day], nil
+}
+
+func testAnswerer(r Reader, now time.Time) *Answerer {
+ loc := time.UTC
+ a := NewAnswerer(r, loc)
+ a.Now = func() time.Time { return now.In(loc) }
+ return a
+}
+
+func mustAnswer(t *testing.T, a *Answerer, req AskRequest) AskResponse {
+ t.Helper()
+ return a.Answer(context.Background(), req)
+}
+
+func answerTextOf(t *testing.T, resp AskResponse) string {
+ t.Helper()
+ if resp.Answer == nil {
+ t.Fatalf("answer = nil, reason = %q", resp.Reason)
+ }
+ return *resp.Answer
+}
+
+func TestScreenTimeToday(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{
+ {ID: 1, Name: "Harry's phone", Child: "Harry"},
+ {ID: 2, Name: "Finn's computer", Child: "Finn"},
+ },
+ usage: map[string][]store.DeviceDayUsage{
+ "2026-10-13": {
+ {DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 3600, DomainSeconds: 900},
+ // app and domain for the same session must not be summed.
+ {DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 1800, DomainSeconds: 2000},
+ },
+ },
+ }
+ a := testAnswerer(r, now)
+
+ resp := mustAnswer(t, a, AskRequest{
+ Question: "how long have the kids been on screens today?",
+ Asker: "sam",
+ })
+ got := answerTextOf(t, resp)
+ if !strings.Contains(got, "1h 30m") {
+ t.Errorf("answer = %q, want 1h 30m", got)
+ }
+ if total, _ := resp.Data["total_seconds"].(int); total != 5400 {
+ t.Errorf("total_seconds = %v, want 5400 (app totals, no double count)", resp.Data["total_seconds"])
+ }
+}
+
+func TestScreenTimeOneChild(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{
+ {ID: 1, Name: "Harry's phone", Child: "Harry"},
+ {ID: 2, Name: "Finn's computer", Child: "Finn"},
+ },
+ usage: map[string][]store.DeviceDayUsage{
+ "2026-10-13": {
+ {DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 2700},
+ {DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 900},
+ },
+ },
+ }
+ a := testAnswerer(r, now)
+ resp := mustAnswer(t, a, AskRequest{Question: "how long has Harry been on screens?", Asker: "sam"})
+ if got := answerTextOf(t, resp); !strings.Contains(got, "45m") || !strings.Contains(got, "Harry") {
+ t.Errorf("answer = %q, want Harry 45m", got)
+ }
+}
+
+func TestScreenTimeYesterday(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
+ usage: map[string][]store.DeviceDayUsage{
+ "2026-10-12": {{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 120}},
+ },
+ }
+ a := testAnswerer(r, now)
+ resp := mustAnswer(t, a, AskRequest{Question: "how long were the kids on screens yesterday?", Asker: "sam"})
+ if got := answerTextOf(t, resp); !strings.Contains(got, "yesterday") {
+ t.Errorf("answer = %q, want yesterday", got)
+ }
+}
+
+func TestScreenTimeNoneRecorded(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
+ usage: map[string][]store.DeviceDayUsage{},
+ }
+ a := testAnswerer(r, now)
+ resp := mustAnswer(t, a, AskRequest{Question: "how long have the kids been on screens today?", Asker: "sam"})
+ if got := answerTextOf(t, resp); !strings.Contains(got, "no recorded screen time") {
+ t.Errorf("answer = %q", got)
+ }
+}
+
+func TestBlockedAnswer(t *testing.T) {
+ now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
+ until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
+ eff: map[int64]store.EffectiveState{
+ 1: {DeviceID: 1, Locked: true, Reason: "schedule", Until: &until},
+ },
+ }
+ a := testAnswerer(r, now)
+ resp := mustAnswer(t, a, AskRequest{Question: "what is blocked right now?", Asker: "sam"})
+ if got := answerTextOf(t, resp); !strings.Contains(got, "Harry's phone") || !strings.Contains(got, "21:00") {
+ t.Errorf("answer = %q", got)
+ }
+}
+
+func TestPresenceAnswer(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ lastSeen := now.Add(-2*time.Hour - 10*time.Minute)
+ r := &fakeReader{
+ devices: []store.Device{
+ {ID: 1, Name: "Harry's phone", Child: "Harry", Online: true},
+ {ID: 2, Name: "Finn's computer", Child: "Finn", Online: false, LastSeen: &lastSeen},
+ },
+ }
+ a := testAnswerer(r, now)
+ resp := mustAnswer(t, a, AskRequest{Question: "are the devices online?", Asker: "sam"})
+ if got := answerTextOf(t, resp); !strings.Contains(got, "Finn's computer") || !strings.Contains(got, "2h 10m") {
+ t.Errorf("answer = %q", got)
+ }
+}
+
+func TestPermissionsAndUnknowns(t *testing.T) {
+ now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
+ r := &fakeReader{
+ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
+ }
+ a := testAnswerer(r, now)
+
+ cases := []struct {
+ name string
+ req AskRequest
+ want string
+ }{
+ {"no asker", AskRequest{Question: "screen time?"}, "no asker"},
+ {"not sam", AskRequest{Question: "screen time?", Asker: "harry"}, "only answer for Sam"},
+ {"unknown person", AskRequest{Question: "screen time?", Asker: "sam", Person: "jo"}, "no data for that person"},
+ {"unknown intent", AskRequest{Question: "what is the weather?", Asker: "sam"}, "I can answer"},
+ }
+ for _, tc := range cases {
+ resp := mustAnswer(t, a, tc.req)
+ if resp.Answer != nil {
+ t.Errorf("%s: answer = %q, want nil", tc.name, *resp.Answer)
+ continue
+ }
+ if !strings.Contains(resp.Reason, tc.want) {
+ t.Errorf("%s: reason = %q, want to contain %q", tc.name, resp.Reason, tc.want)
+ }
+ }
+ if !strings.Contains(mustAnswer(t, a, AskRequest{Question: "", Asker: "sam"}).Reason, "no question") {
+ t.Error("empty question should be refused")
+ }
+}
+
+func TestDefaultCapabilities(t *testing.T) {
+ c := DefaultCapabilities()
+ if c.Project != "internet_use_control" {
+ t.Errorf("project = %q", c.Project)
+ }
+ if len(c.Topics) == 0 || len(c.People) == 0 {
+ t.Errorf("capabilities = %#v", c)
+ }
+}
+
+func TestScreenSecondsPrefersApp(t *testing.T) {
+ if got := ScreenSeconds(600, 900); got != 600 {
+ t.Errorf("screenSeconds(600,900) = %d, want 600", got)
+ }
+ if got := ScreenSeconds(0, 900); got != 900 {
+ t.Errorf("screenSeconds(0,900) = %d, want 900", got)
+ }
+}
diff --git a/internal/hub/hub.go b/internal/hub/hub.go
new file mode 100644
index 0000000..1405ced
--- /dev/null
+++ b/internal/hub/hub.go
@@ -0,0 +1,74 @@
+// Package hub implements the project side of the Hub contract
+// (central_ai_llm_master/docs/ask-contract.md) for the Internet Use Control
+// Control Service.
+//
+// The contract is one-directional by design: the Hub asks a project about its
+// own data and the project answers. A project never learns the Hub's data model
+// and the Hub never learns the project's.
+//
+// GET /capabilities -> the topics this project can answer (Hub routing)
+// POST /ask -> a short answer about this project's data
+// POST /notify -> accept a notification and deliver it to Sam
+//
+// /ask and /capabilities are read-only. /ask answers from SQLite and never
+// writes, so the "read only" promise (AGENTS.md §10 rule 9) is a testable
+// property, not a comment (TestAskWritesNothing).
+package hub
+
+// ProjectName is the value the Hub matches in routing.md.
+const ProjectName = "internet_use_control"
+
+// Capabilities is the GET /capabilities reply. The Hub reads it to route a
+// question to this project. Keep the topic list short and plain; it holds no
+// private data.
+type Capabilities struct {
+ Project string `json:"project"`
+ Topics []string `json:"topics"`
+ People []string `json:"people"`
+}
+
+// DefaultCapabilities returns the topics this service can answer. They match
+// the screen-time domain in AGENTS.md §1 and §4.
+func DefaultCapabilities() Capabilities {
+ return Capabilities{
+ Project: ProjectName,
+ Topics: []string{"internet", "screen time", "screens", "usage", "devices", "blocking", "kids"},
+ People: []string{"harry", "finn"},
+ }
+}
+
+// AskRequest is the POST /ask body (ask-contract.md).
+type AskRequest struct {
+ Question string `json:"question"`
+ Asker string `json:"asker"`
+ Person string `json:"person,omitempty"`
+ Source string `json:"source,omitempty"`
+}
+
+// AskResponse is the POST /ask reply. A nil Answer means the project cannot
+// answer and Reason explains why. The shape is frozen by the contract.
+type AskResponse struct {
+ Answer *string `json:"answer"`
+ Data map[string]any `json:"data,omitempty"`
+ Expires string `json:"expires,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The
+// contract defines it as project-to-Hub; this service accepts the same shape so
+// a caller (Hub, admin app, or a future phone tamper report) can have Sam
+// notified without inventing a second shape.
+type NotifyRequest struct {
+ From string `json:"from"`
+ Level string `json:"level"`
+ Message string `json:"message"`
+ Person string `json:"person,omitempty"`
+}
+
+// answerText is a small helper so pointer handling stays in one place.
+func answerText(s string) *string { return &s }
+
+// cannotAnswer builds the contract's "cannot answer" reply.
+func cannotAnswer(reason string) AskResponse {
+ return AskResponse{Answer: nil, Reason: reason}
+}
diff --git a/internal/store/alerts.go b/internal/store/alerts.go
new file mode 100644
index 0000000..0fa3823
--- /dev/null
+++ b/internal/store/alerts.go
@@ -0,0 +1,224 @@
+// Alerts (AGENTS.md §4 FR-13, §7 retention, §11 Phase 9).
+//
+// An alert is a message the Control Service decided Sam should see: a device
+// hit its limit (a blocking window turned on), or a device has been offline too
+// long. The engine (internal/alert) raises them and delivers them through
+// Apprise; this file only records what happened.
+//
+// `alerts` is append-only except for the delivery outcome, so an operator can
+// always tell what fired and whether it reached Sam. `alert_state` is the small
+// per-device dedup memory that turns a transition into exactly one alert.
+package store
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+)
+
+// Alert kinds. 'limit' is a device that became blocked, 'offline' is a device
+// unreachable for too long, 'notify' is an accepted notification delivered on
+// demand (for example the future phone tamper report, FR-17).
+const (
+ AlertLimit = "limit"
+ AlertOffline = "offline"
+ AlertNotify = "notify"
+)
+
+// Alert is one recorded alert.
+type Alert struct {
+ ID int64
+ DeviceID *int64
+ DeviceName string
+ Kind string
+ Message string
+ CreatedAt time.Time
+ Delivered bool
+ DeliveredAt *time.Time
+ Error string
+}
+
+// AlertState is the last observed condition for one device. It exists only to
+// suppress duplicate alerts while a condition stays true.
+type AlertState struct {
+ Locked bool
+ Offline bool
+}
+
+// CreateAlert records one alert and appends it to the audit log in the same
+// transaction (FR-15). deviceID may be nil for a notification with no device.
+func (s *Store) CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*Alert, error) {
+ switch kind {
+ case AlertLimit, AlertOffline, AlertNotify:
+ default:
+ return nil, fmt.Errorf("create alert: unknown kind %q", kind)
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create alert: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ var idArg any
+ if deviceID != nil {
+ idArg = *deviceID
+ }
+ res, err := tx.ExecContext(ctx,
+ `INSERT INTO alerts (device_id, kind, message) VALUES (?, ?, ?)`,
+ idArg, kind, message)
+ if err != nil {
+ return nil, fmt.Errorf("create alert: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, fmt.Errorf("create alert id: %w", err)
+ }
+ if err := insertAudit(ctx, tx, "alert", "alert."+kind, deviceID, message); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit alert: %w", err)
+ }
+ return s.GetAlert(ctx, id)
+}
+
+// MarkAlertDelivered records the delivery outcome. An empty errMsg means Apprise
+// accepted the notification.
+func (s *Store) MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error {
+ delivered := 0
+ if errMsg == "" {
+ delivered = 1
+ }
+ if _, err := s.db.ExecContext(ctx, `
+ UPDATE alerts SET delivered = ?, delivered_at = datetime('now'), error = ?
+ WHERE id = ?`, delivered, nullString(errMsg), id); err != nil {
+ return fmt.Errorf("mark alert delivered: %w", err)
+ }
+ return nil
+}
+
+// GetAlert returns one alert, or ErrNotFound.
+func (s *Store) GetAlert(ctx context.Context, id int64) (*Alert, error) {
+ row := s.db.QueryRowContext(ctx, `
+ SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
+ a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
+ FROM alerts a
+ LEFT JOIN devices d ON d.id = a.device_id
+ WHERE a.id = ?`, id)
+ a, err := scanAlert(row)
+ if err == sql.ErrNoRows {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get alert: %w", err)
+ }
+ return a, nil
+}
+
+// ListAlerts returns the newest alerts first, for the Alerts screen and for
+// verification. A non-positive or oversized limit is clamped.
+func (s *Store) ListAlerts(ctx context.Context, limit int) ([]Alert, error) {
+ if limit <= 0 || limit > 500 {
+ limit = 50
+ }
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
+ a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
+ FROM alerts a
+ LEFT JOIN devices d ON d.id = a.device_id
+ ORDER BY a.id DESC
+ LIMIT ?`, limit)
+ if err != nil {
+ return nil, fmt.Errorf("list alerts: %w", err)
+ }
+ defer rows.Close()
+
+ out := make([]Alert, 0, limit)
+ for rows.Next() {
+ a, err := scanAlert(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan alert: %w", err)
+ }
+ out = append(out, *a)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("list alerts: %w", err)
+ }
+ return out, nil
+}
+
+// AlertStates returns the dedup state for every device that has one. A device
+// with no row is a device the engine has not seen yet.
+func (s *Store) AlertStates(ctx context.Context) (map[int64]AlertState, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT device_id, locked, offline FROM alert_state`)
+ if err != nil {
+ return nil, fmt.Errorf("alert states: %w", err)
+ }
+ defer rows.Close()
+
+ out := make(map[int64]AlertState)
+ for rows.Next() {
+ var (
+ id int64
+ locked, offln int
+ )
+ if err := rows.Scan(&id, &locked, &offln); err != nil {
+ return nil, fmt.Errorf("scan alert state: %w", err)
+ }
+ out[id] = AlertState{Locked: locked != 0, Offline: offln != 0}
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("alert states: %w", err)
+ }
+ return out, nil
+}
+
+// SetAlertState records the last observed condition for one device.
+func (s *Store) SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error {
+ if _, err := s.db.ExecContext(ctx, `
+ INSERT INTO alert_state (device_id, locked, offline, updated_at)
+ VALUES (?, ?, ?, datetime('now'))
+ ON CONFLICT(device_id) DO UPDATE SET
+ locked = excluded.locked,
+ offline = excluded.offline,
+ updated_at = datetime('now')`,
+ deviceID, boolToInt(locked), boolToInt(offline)); err != nil {
+ return fmt.Errorf("set alert state: %w", err)
+ }
+ return nil
+}
+
+func scanAlert(row interface {
+ Scan(dest ...any) error
+}) (*Alert, error) {
+ var (
+ a Alert
+ deviceID sql.NullInt64
+ created sql.NullString
+ delivered int
+ deliveredAt sql.NullString
+ )
+ if err := row.Scan(&a.ID, &deviceID, &a.DeviceName, &a.Kind, &a.Message,
+ &created, &delivered, &deliveredAt, &a.Error); err != nil {
+ return nil, err
+ }
+ if deviceID.Valid {
+ id := deviceID.Int64
+ a.DeviceID = &id
+ }
+ a.CreatedAt = parseTime(created.String)
+ a.Delivered = delivered != 0
+ a.DeliveredAt = parseTimePtr(deliveredAt)
+ return &a, nil
+}
+
+// nullString maps an empty message to SQL NULL, so an undelivered-without-error
+// row is distinguishable from a delivered one.
+func nullString(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
diff --git a/internal/store/alerts_test.go b/internal/store/alerts_test.go
new file mode 100644
index 0000000..56586d0
--- /dev/null
+++ b/internal/store/alerts_test.go
@@ -0,0 +1,134 @@
+package store
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestAlertLifecycle(t *testing.T) {
+ ctx := context.Background()
+ st := testStore(t)
+ dev := mustCreateDevice(t, st, "Harry's phone", "Harry", "android")
+
+ a, err := st.CreateAlert(ctx, &dev.ID, AlertLimit, "Harry's phone reached its limit.")
+ if err != nil {
+ t.Fatalf("CreateAlert: %v", err)
+ }
+ if a.Delivered {
+ t.Error("a fresh alert must not be delivered")
+ }
+ if a.DeviceName != "Harry's phone" {
+ t.Errorf("device name = %q", a.DeviceName)
+ }
+
+ // The alert is audited.
+ audit, err := st.ListAudit(ctx, 10)
+ if err != nil {
+ t.Fatalf("ListAudit: %v", err)
+ }
+ if len(audit) == 0 || audit[0].Action != "alert.limit" {
+ t.Fatalf("audit = %#v, want an alert.limit row", audit)
+ }
+
+ if err := st.MarkAlertDelivered(ctx, a.ID, ""); err != nil {
+ t.Fatalf("MarkAlertDelivered: %v", err)
+ }
+ got, err := st.GetAlert(ctx, a.ID)
+ if err != nil {
+ t.Fatalf("GetAlert: %v", err)
+ }
+ if !got.Delivered || got.DeliveredAt == nil || got.Error != "" {
+ t.Errorf("delivered alert = %#v", got)
+ }
+
+ // A delivery failure is recorded, not hidden.
+ b, err := st.CreateAlert(ctx, nil, AlertNotify, "message")
+ if err != nil {
+ t.Fatalf("CreateAlert notify: %v", err)
+ }
+ if err := st.MarkAlertDelivered(ctx, b.ID, "post apprise: status 500"); err != nil {
+ t.Fatalf("MarkAlertDelivered: %v", err)
+ }
+ got, err = st.GetAlert(ctx, b.ID)
+ if err != nil {
+ t.Fatalf("GetAlert: %v", err)
+ }
+ if got.Delivered || got.Error != "post apprise: status 500" {
+ t.Errorf("failed alert = %#v", got)
+ }
+
+ if _, err := st.CreateAlert(ctx, nil, "bogus", "x"); err == nil {
+ t.Error("CreateAlert accepted an unknown kind")
+ }
+}
+
+func TestAlertStatesRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ st := testStore(t)
+ dev := mustCreateDevice(t, st, "Finn's computer", "Finn", "windows")
+
+ states, err := st.AlertStates(ctx)
+ if err != nil {
+ t.Fatalf("AlertStates: %v", err)
+ }
+ if len(states) != 0 {
+ t.Fatalf("states = %#v, want empty", states)
+ }
+
+ if err := st.SetAlertState(ctx, dev.ID, true, false); err != nil {
+ t.Fatalf("SetAlertState: %v", err)
+ }
+ states, err = st.AlertStates(ctx)
+ if err != nil {
+ t.Fatalf("AlertStates: %v", err)
+ }
+ if st := states[dev.ID]; !st.Locked || st.Offline {
+ t.Fatalf("state = %#v, want locked only", st)
+ }
+
+ if err := st.SetAlertState(ctx, dev.ID, false, true); err != nil {
+ t.Fatalf("SetAlertState: %v", err)
+ }
+ states, _ = st.AlertStates(ctx)
+ if st := states[dev.ID]; st.Locked || !st.Offline {
+ t.Fatalf("state = %#v, want offline only", st)
+ }
+}
+
+func TestUsageByDeviceForDay(t *testing.T) {
+ ctx := context.Background()
+ st := testStore(t)
+ harry := mustCreateDevice(t, st, "Harry's phone", "Harry", "android")
+ finn := mustCreateDevice(t, st, "Finn's computer", "Finn", "windows")
+ day := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
+
+ if _, err := st.InsertTelemetry(ctx, harry.ID, day, []TelemetryEntry{
+ {App: "com.spotify.music", Seconds: 3600},
+ {Domain: "youtube.com", Seconds: 900},
+ }); err != nil {
+ t.Fatalf("InsertTelemetry: %v", err)
+ }
+ if _, err := st.InsertTelemetry(ctx, finn.ID, day, []TelemetryEntry{{App: "chrome", Seconds: 1800}}); err != nil {
+ t.Fatalf("InsertTelemetry: %v", err)
+ }
+
+ rows, err := st.UsageByDeviceForDay(ctx, day)
+ if err != nil {
+ t.Fatalf("UsageByDeviceForDay: %v", err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("rows = %d, want 2", len(rows))
+ }
+ if rows[0].Name != "Harry's phone" || rows[0].AppSeconds != 3600 || rows[0].DomainSeconds != 900 {
+ t.Errorf("harry row = %#v", rows[0])
+ }
+ if rows[1].Child != "Finn" || rows[1].AppSeconds != 1800 {
+ t.Errorf("finn row = %#v", rows[1])
+ }
+
+ // A day with no reports returns nothing.
+ if rows, err := st.UsageByDeviceForDay(ctx, "2000-01-01"); err != nil || len(rows) != 0 {
+ t.Fatalf("empty day: rows=%#v err=%v", rows, err)
+ }
+}
diff --git a/internal/store/migrations/005_alerts.sql b/internal/store/migrations/005_alerts.sql
new file mode 100644
index 0000000..2eab125
--- /dev/null
+++ b/internal/store/migrations/005_alerts.sql
@@ -0,0 +1,33 @@
+-- Migration 005 — Phase 9. Alerts and their dedup state (AGENTS.md §4 FR-13,
+-- §5 screen 6, §11 Phase 9).
+--
+-- `alerts` is the append-only record of every alert the Control Service raised:
+-- a schedule/limit hit, a device offline too long, or an accepted notify. It is
+-- what the Alerts screen reads and what the delivery evidence is checked
+-- against. Nothing is ever edited except the delivery outcome columns.
+--
+-- `alert_state` remembers the last observed condition per device, so an alert
+-- fires once on a transition (unlocked -> locked, reachable -> offline-too-long)
+-- instead of on every poll. It is derived state, safe to delete; the service
+-- re-seeds it without alerting.
+
+CREATE TABLE IF NOT EXISTS alerts (
+ id INTEGER PRIMARY KEY,
+ device_id INTEGER REFERENCES devices(id),
+ kind TEXT NOT NULL CHECK (kind IN ('limit', 'offline', 'notify')),
+ message TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ delivered INTEGER NOT NULL DEFAULT 0,
+ delivered_at TEXT,
+ error TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at);
+CREATE INDEX IF NOT EXISTS idx_alerts_device ON alerts(device_id);
+
+CREATE TABLE IF NOT EXISTS alert_state (
+ device_id INTEGER PRIMARY KEY REFERENCES devices(id),
+ locked INTEGER NOT NULL DEFAULT 0,
+ offline INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/internal/store/reports.go b/internal/store/reports.go
index edb59b3..38e2cf5 100644
--- a/internal/store/reports.go
+++ b/internal/store/reports.go
@@ -260,6 +260,79 @@ func (s *Store) TopUsage(ctx context.Context, id int64, from, to, kind string, l
return out, nil
}
+// DeviceDayUsage is one device's rolled-up usage for one day. AppSeconds and
+// DomainSeconds are kept apart for the same reason as the rollup: a report may
+// carry both for one session, so adding them together would double-count time.
+// The screen-time answer decides how to combine them (internal/hub).
+type DeviceDayUsage struct {
+ DeviceID int64
+ Name string
+ Child string
+ AppSeconds int
+ DomainSeconds int
+}
+
+// UsageByDeviceForDay returns the daily rollup for every device that reported
+// anything on day (YYYY-MM-DD). It is the read the screen-time answer is built
+// from. Devices with no row are absent; the caller treats them as zero.
+func (s *Store) UsageByDeviceForDay(ctx context.Context, day string) ([]DeviceDayUsage, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT d.id, d.name, d.child, td.app_seconds, td.domain_seconds
+ FROM telemetry_daily td
+ JOIN devices d ON d.id = td.device_id
+ WHERE td.day = ?
+ ORDER BY d.id`, day)
+ if err != nil {
+ return nil, fmt.Errorf("usage by device for day: %w", err)
+ }
+ defer rows.Close()
+
+ out := make([]DeviceDayUsage, 0, 8)
+ for rows.Next() {
+ var u DeviceDayUsage
+ if err := rows.Scan(&u.DeviceID, &u.Name, &u.Child, &u.AppSeconds, &u.DomainSeconds); err != nil {
+ return nil, fmt.Errorf("scan device day usage: %w", err)
+ }
+ out = append(out, u)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("usage by device for day: %w", err)
+ }
+ return out, nil
+}
+
+// DetailUsageByDeviceForDay is the same measure computed straight from the
+// telemetry detail table instead of the derived rollup. It exists so an
+// operator can independently check the rollup the daily answer reads.
+func (s *Store) DetailUsageByDeviceForDay(ctx context.Context, day string) ([]DeviceDayUsage, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT d.id, d.name, d.child,
+ COALESCE(SUM(CASE WHEN t.app <> '' THEN t.seconds END), 0),
+ COALESCE(SUM(CASE WHEN t.domain <> '' THEN t.seconds END), 0)
+ FROM telemetry t
+ JOIN devices d ON d.id = t.device_id
+ WHERE t.day = ?
+ GROUP BY d.id
+ ORDER BY d.id`, day)
+ if err != nil {
+ return nil, fmt.Errorf("detail usage by device for day: %w", err)
+ }
+ defer rows.Close()
+
+ out := make([]DeviceDayUsage, 0, 8)
+ for rows.Next() {
+ var u DeviceDayUsage
+ if err := rows.Scan(&u.DeviceID, &u.Name, &u.Child, &u.AppSeconds, &u.DomainSeconds); err != nil {
+ return nil, fmt.Errorf("scan detail device day usage: %w", err)
+ }
+ out = append(out, u)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("detail usage by device for day: %w", err)
+ }
+ return out, nil
+}
+
// CapAudit keeps the newest max audit rows and deletes the oldest beyond that,
// so the append-only log cannot grow without bound (AGENTS.md §7 retention). It
// returns how many rows it removed. Nothing in the audit log is ever edited.
diff --git a/internal/web/hub_api.go b/internal/web/hub_api.go
new file mode 100644
index 0000000..761fc68
--- /dev/null
+++ b/internal/web/hub_api.go
@@ -0,0 +1,192 @@
+// The Hub contract surface (AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9).
+//
+// The project side of central_ai_llm_master/docs/ask-contract.md:
+//
+// GET /capabilities -> the topics this service answers (Hub routing)
+// POST /ask -> a short answer about screen time and devices
+// POST /notify -> accept a notification and deliver it through Apprise
+//
+// All three require the shared Hub bearer token. A service-to-service call must
+// never be trusted unsigned. /ask and /capabilities are read-only: they call
+// only store read methods (AGENTS.md §10 rule 9). /notify is the one write on
+// this surface, and it only records and delivers a message; it never changes
+// device state.
+package web
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+// hubMaxBody bounds a Hub request body. Questions and notifications are small.
+const hubMaxBody = 64 << 10 // 64 KiB
+
+// notifyResponse is this service's own reply to POST /notify. The contract does
+// not freeze a response shape for the receiver, so this is kept minimal.
+type notifyResponse struct {
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+}
+
+// hubAuthRequired writes the contract-shaped refusal when the Hub API has no
+// token. With no token the endpoints refuse to serve rather than trust an
+// unsigned request.
+func (s *Server) hubAuthRequired(w http.ResponseWriter) bool {
+ if !s.cfg.HubConfigured() {
+ writeHubJSON(w, http.StatusServiceUnavailable, hub.AskResponse{
+ Answer: nil,
+ Reason: "the Hub API is not configured",
+ })
+ return false
+ }
+ return true
+}
+
+// requireHub enforces the shared bearer token on the Hub endpoints.
+func (s *Server) requireHub(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !s.hubAuthRequired(w) {
+ return
+ }
+ token, ok := bearerToken(r)
+ if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.HubToken)) != 1 {
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
+ _ = s.store.Audit(ctx, "anonymous", "hub.token_rejected", nil, r.URL.Path+" from "+clientIP(r))
+ cancel()
+ s.log.Warn("hub api rejected", "path", r.URL.Path, "remote", r.RemoteAddr)
+ w.Header().Set("WWW-Authenticate", `Bearer realm="internet-use-control"`)
+ writeHubJSON(w, http.StatusUnauthorized, hub.AskResponse{
+ Answer: nil,
+ Reason: "unauthorized",
+ })
+ return
+ }
+ next(w, r)
+ }
+}
+
+// handleCapabilities returns the routing topics for the Hub. It holds no
+// private data.
+func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
+ writeHubJSON(w, http.StatusOK, hub.DefaultCapabilities())
+}
+
+// handleAsk answers one question from this project's read-only data. The reply
+// is always HTTP 200 with either an answer or a reason, so the Hub can relay a
+// "cannot answer" without treating the project as down.
+func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
+ defer cancel()
+
+ var req hub.AskRequest
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
+ if err := dec.Decode(&req); err != nil {
+ writeHubJSON(w, http.StatusOK, hub.AskResponse{
+ Answer: nil,
+ Reason: "the request body is not valid JSON",
+ })
+ return
+ }
+
+ started := time.Now()
+ resp := s.answerer.Answer(ctx, req)
+ s.log.Info("hub ask",
+ "asker", req.Asker,
+ "person", req.Person,
+ "source", req.Source,
+ "answered", resp.Answer != nil,
+ "duration_ms", time.Since(started).Milliseconds(),
+ )
+ writeHubJSON(w, http.StatusOK, resp)
+}
+
+// handleNotify accepts a notification in the frozen contract's shape and
+// delivers it to Sam through Apprise. It is how a tamper report from a phone
+// client (FR-17) will reach Sam without inventing a second shape. The message
+// is recorded before delivery so a delivery failure is visible.
+func (s *Server) handleNotify(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+
+ var req hub.NotifyRequest
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
+ if err := dec.Decode(&req); err != nil {
+ writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "invalid JSON body"})
+ return
+ }
+
+ from := strings.TrimSpace(req.From)
+ if from == "" {
+ from = hub.ProjectName
+ }
+ level := strings.ToLower(strings.TrimSpace(req.Level))
+ switch level {
+ case "":
+ level = "info"
+ case "info", "important", "urgent":
+ default:
+ writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "level must be info, important or urgent"})
+ return
+ }
+ message := strings.TrimSpace(req.Message)
+ if message == "" {
+ writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "message is required"})
+ return
+ }
+ if len(message) > 500 {
+ message = message[:500]
+ }
+
+ detail := fmt.Sprintf("[%s] %s — from %s", level, message, from)
+ record, err := s.store.CreateAlert(ctx, nil, store.AlertNotify, detail)
+ if err != nil {
+ s.log.Error("notify record", "from", from, "err", err)
+ writeHubJSON(w, http.StatusInternalServerError, notifyResponse{Error: "could not record the notification"})
+ return
+ }
+
+ if !s.apprise.Configured() {
+ _ = s.store.MarkAlertDelivered(ctx, record.ID, "apprise is not configured")
+ writeHubJSON(w, http.StatusServiceUnavailable, notifyResponse{Error: "apprise is not configured"})
+ return
+ }
+ if err := s.apprise.SendType(ctx, "Internet Use Control — "+from, message, notifyType(level)); err != nil {
+ _ = s.store.MarkAlertDelivered(ctx, record.ID, err.Error())
+ s.log.Error("notify delivery", "from", from, "err", err)
+ writeHubJSON(w, http.StatusBadGateway, notifyResponse{Error: "delivery failed"})
+ return
+ }
+ if err := s.store.MarkAlertDelivered(ctx, record.ID, ""); err != nil {
+ s.log.Error("notify record", "id", record.ID, "err", err)
+ }
+ s.log.Info("notify delivered", "from", from, "level", level, "person", req.Person, "alert_id", record.ID)
+ writeHubJSON(w, http.StatusOK, notifyResponse{OK: true})
+}
+
+// notifyType maps the contract's level to an Apprise notification class.
+func notifyType(level string) string {
+ switch level {
+ case "important":
+ return apprise.TypeWarning
+ case "urgent":
+ return apprise.TypeFailure
+ default:
+ return apprise.TypeInfo
+ }
+}
+
+// writeHubJSON writes one JSON value with the given status.
+func writeHubJSON(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/internal/web/hub_api_test.go b/internal/web/hub_api_test.go
new file mode 100644
index 0000000..680664b
--- /dev/null
+++ b/internal/web/hub_api_test.go
@@ -0,0 +1,250 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
+)
+
+const testHubToken = "test-hub-token"
+
+// newHubTestApp builds a server with a real store and explicit Hub/Apprise
+// configuration.
+func newHubTestApp(t *testing.T, hubToken, appriseBase string) (*Server, *store.Store) {
+ t.Helper()
+ st, err := store.Open(filepath.Join(t.TempDir(), "control.db"))
+ if err != nil {
+ t.Fatalf("store.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = st.Close() })
+ if err := st.Migrate(context.Background()); err != nil {
+ t.Fatalf("Migrate: %v", err)
+ }
+ am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
+ if err != nil {
+ t.Fatalf("auth.New: %v", err)
+ }
+ cfg := &config.Config{
+ AdminUsername: "sam",
+ Version: "test",
+ AppEnv: "test",
+ DBPath: ":memory:",
+ ScheduleLocation: time.UTC,
+ HubToken: hubToken,
+ AppriseURL: appriseBase,
+ AppriseURLs: []string{"mailto:sam@example.com"},
+ }
+ srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
+ if err != nil {
+ t.Fatalf("web.New: %v", err)
+ }
+ return srv, st
+}
+
+func hubRequest(t *testing.T, h http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ var reader io.Reader
+ if body != "" {
+ reader = strings.NewReader(body)
+ }
+ req := httptest.NewRequest(method, path, reader)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestCapabilitiesRequiresToken(t *testing.T) {
+ srv, _ := newHubTestApp(t, testHubToken, "")
+
+ rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
+ }
+
+ rec = hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", testHubToken)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Project string `json:"project"`
+ Topics []string `json:"topics"`
+ People []string `json:"people"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body.Project != "internet_use_control" {
+ t.Errorf("project = %q", body.Project)
+ }
+ if len(body.Topics) == 0 {
+ t.Errorf("topics empty")
+ }
+}
+
+func TestAskRequiresTokenAndAnswers(t *testing.T) {
+ srv, st := newHubTestApp(t, testHubToken, "")
+ ctx := context.Background()
+ dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test")
+ if err != nil {
+ t.Fatalf("CreateDevice: %v", err)
+ }
+ today := time.Now().In(time.UTC).Format("2006-01-02")
+ if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{
+ {App: "com.spotify.music", Seconds: 3600},
+ }); err != nil {
+ t.Fatalf("InsertTelemetry: %v", err)
+ }
+
+ // No token: refused.
+ rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask",
+ `{"question":"how long have the kids been on screens today?","asker":"sam"}`, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
+ }
+
+ body := `{"question":"how long have the kids been on screens today?","asker":"sam"}`
+ rec = hubRequest(t, srv.Handler(), http.MethodPost, "/ask", body, testHubToken)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ Answer *string `json:"answer"`
+ Data struct {
+ TotalSeconds int `json:"total_seconds"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.Answer == nil || !strings.Contains(*resp.Answer, "1h 00m") {
+ t.Fatalf("answer = %v, want 1h 00m", resp.Answer)
+ }
+ if resp.Data.TotalSeconds != 3600 {
+ t.Errorf("total_seconds = %d, want 3600", resp.Data.TotalSeconds)
+ }
+}
+
+func TestAskWritesNothing(t *testing.T) {
+ srv, st := newHubTestApp(t, testHubToken, "")
+ ctx := context.Background()
+ dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test")
+ if err != nil {
+ t.Fatalf("CreateDevice: %v", err)
+ }
+ today := time.Now().In(time.UTC).Format("2006-01-02")
+ if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{{App: "youtube", Seconds: 600}}); err != nil {
+ t.Fatalf("InsertTelemetry: %v", err)
+ }
+
+ countRows := func() int {
+ var n int
+ if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry`).Scan(&n); err != nil {
+ t.Fatalf("count telemetry: %v", err)
+ }
+ return n
+ }
+ var auditBefore int
+ if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditBefore); err != nil {
+ t.Fatalf("count audit: %v", err)
+ }
+
+ questions := []string{
+ `{"question":"how long have the kids been on screens today?","asker":"sam"}`,
+ `{"question":"what is blocked?","asker":"sam"}`,
+ `{"question":"are the devices online?","asker":"sam"}`,
+ `{"question":"what is the weather?","asker":"sam"}`,
+ }
+ for _, q := range questions {
+ rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask", q, testHubToken)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("ask status = %d (%s)", rec.Code, rec.Body.String())
+ }
+ }
+ if got := countRows(); got != 1 {
+ t.Errorf("telemetry rows = %d, want 1 (ask must not write)", got)
+ }
+ var auditAfter int
+ if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditAfter); err != nil {
+ t.Fatalf("count audit: %v", err)
+ }
+ if auditAfter != auditBefore {
+ t.Errorf("audit rows changed from %d to %d; ask must not write", auditBefore, auditAfter)
+ }
+}
+
+func TestNotifyDeliversThroughApprise(t *testing.T) {
+ var gotPath string
+ var gotBody map[string]any
+ stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &gotBody)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer stub.Close()
+
+ srv, st := newHubTestApp(t, testHubToken, stub.URL)
+ rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify",
+ `{"from":"internet_use_control","level":"important","message":"3 emails need a decision.","person":"sam"}`,
+ testHubToken)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
+ }
+ if gotPath != "/notify" {
+ t.Errorf("apprise path = %q, want /notify", gotPath)
+ }
+ if gotBody["body"] != "3 emails need a decision." {
+ t.Errorf("apprise body = %#v", gotBody)
+ }
+ if gotBody["type"] != "warning" {
+ t.Errorf("apprise type = %v, want warning", gotBody["type"])
+ }
+
+ alerts, err := st.ListAlerts(context.Background(), 10)
+ if err != nil {
+ t.Fatalf("ListAlerts: %v", err)
+ }
+ if len(alerts) != 1 || alerts[0].Kind != store.AlertNotify || !alerts[0].Delivered {
+ t.Fatalf("alerts = %#v, want one delivered notify", alerts)
+ }
+}
+
+func TestNotifyValidatesInput(t *testing.T) {
+ srv, _ := newHubTestApp(t, testHubToken, "")
+ cases := []string{
+ `{"from":"x","level":"loud","message":"hi"}`,
+ `{"from":"x","level":"info","message":""}`,
+ `not json`,
+ }
+ for _, body := range cases {
+ rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify", body, testHubToken)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("body %q: status = %d, want 400", body, rec.Code)
+ }
+ }
+}
+
+func TestHubAPINotConfigured(t *testing.T) {
+ srv, _ := newHubTestApp(t, "", "")
+ rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503 when no Hub token is set", rec.Code)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 9c1b651..cb6d19d 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -15,8 +15,10 @@ import (
"sync"
"time"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
+ "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
@@ -40,6 +42,11 @@ type Server struct {
// code cannot be tried without limit.
keycodeMu sync.Mutex
keycode map[string]keycodeAttempts
+
+ // The Hub contract surface (Phase 9): the read-only answerer and the
+ // Apprise client that delivers alerts and accepted notifications.
+ answerer *hub.Answerer
+ apprise *apprise.Client
}
// Publisher pushes device config to the broker and reports the link's health.
@@ -68,16 +75,22 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{
- cfg: cfg,
- store: st,
- auth: am,
- tmpl: tmpl,
- log: log,
- pub: pub,
- keycode: make(map[string]keycodeAttempts),
+ cfg: cfg,
+ store: st,
+ auth: am,
+ tmpl: tmpl,
+ log: log,
+ pub: pub,
+ keycode: make(map[string]keycodeAttempts),
+ answerer: hub.NewAnswerer(st, cfg.ScheduleLocation),
+ apprise: apprise.New(cfg.AppriseURL, cfg.AppriseKey, cfg.AppriseURLs, cfg.AppriseToken, 0),
}, nil
}
+// Apprise exposes the delivery client so the alert engine (cmd/control) can
+// share the one configured destination with the /notify handler.
+func (s *Server) Apprise() *apprise.Client { return s.apprise }
+
// publishConfig pushes one device's desired state to the broker, retained. A
// publish failure never fails the HTTP request: SQLite already holds the new
// state, and the link republishes everything when it reconnects, so the broker
@@ -131,6 +144,13 @@ func (s *Server) Handler() http.Handler {
// not hold that secret. It is throttled and audited instead (admin_api.go).
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
+ // The Hub contract (Phase 9, FR-12/FR-13). Bearer-token auth, separate
+ // from the dashboard session and the admin API token. /ask is read-only;
+ // /notify records and delivers a message through Apprise.
+ mux.HandleFunc("GET /capabilities", s.requireHub(s.handleCapabilities))
+ mux.HandleFunc("POST /ask", s.requireHub(s.handleAsk))
+ mux.HandleFunc("POST /notify", s.requireHub(s.handleNotify))
+
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())