From 3cf353d9335dcfb9ef6e647e9ef7070d113d5ddb Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 13 Oct 2026 16:41:07 +1100 Subject: [PATCH] Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on Phases 1-8. Adds the project side of the frozen Hub contract (central_ai_llm_master/docs/ask-contract.md) and the alert engine from AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9. - hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a deterministic, read-only Answerer. It answers screen time today/yesterday, what is blocked now, and device presence, scoped to one child or all kids. Screen time is the foreground-app total per device-day; the domain total is used only when a device reports no apps, so a report carrying both is never double-counted. - apprise: internal/apprise is a small Apprise API client (stored key or stateless URL mode, optional bearer token, typed notifications). - alert: internal/alert evaluates device state on an interval and raises one Apprise alert per transition: a limit hit (a device becomes blocked) or a device offline longer than the threshold. A never-seen device is never alerted, and the first observation seeds state without replaying an old limit. The alert is recorded before delivery, with its delivery outcome. - store: migration 005 adds `alerts` (delivery outcome, audited) and `alert_state` (per-device dedup). New usage reads from the telemetry detail table so the daily answer can be checked independently. - web: GET /capabilities, POST /ask (read-only) and POST /notify behind the shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens. /notify accepts the contract's {from,level,message,person} and delivers it through Apprise (the future tamper-report path, FR-17). - cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED, CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and `usage` subcommands for read-only inspection. - deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done; version 0.7.0-phase9. Measured on .13 (2026-10-13): - GET /capabilities -> internet_use_control topics. - POST /ask "how long have the kids been on screens today?" -> "The kids have had 1h 22m of screen time today." (total_seconds=4925); the independent count from the telemetry detail table is also 4925 (app=4925, domain=4800). - A manual lock raised a delivered limit alert (id 4); an MQTT offline status raised a delivered offline alert (id 5); POST /notify delivered (id 6). All three report delivered=1 in `alerts`. Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine. Co-Authored-By: Paperclip --- .gitignore | 3 + Makefile | 11 +- README.md | 133 ++++++++- cmd/control/main.go | 141 +++++++++ deploy/verify-phase9.sh | 203 +++++++++++++ docker-compose.yml | 4 +- environment.d/control-service.env.example | 36 ++- internal/alert/alert.go | 263 +++++++++++++++++ internal/alert/alert_test.go | 178 +++++++++++ internal/apprise/apprise.go | 123 ++++++++ internal/apprise/apprise_test.go | 103 +++++++ internal/config/config.go | 80 +++++ internal/config/config_test.go | 57 ++++ internal/hub/ask.go | 345 ++++++++++++++++++++++ internal/hub/ask_test.go | 211 +++++++++++++ internal/hub/hub.go | 74 +++++ internal/store/alerts.go | 224 ++++++++++++++ internal/store/alerts_test.go | 134 +++++++++ internal/store/migrations/005_alerts.sql | 33 +++ internal/store/reports.go | 73 +++++ internal/web/hub_api.go | 192 ++++++++++++ internal/web/hub_api_test.go | 250 ++++++++++++++++ internal/web/server.go | 34 ++- 23 files changed, 2881 insertions(+), 24 deletions(-) create mode 100755 deploy/verify-phase9.sh create mode 100644 internal/alert/alert.go create mode 100644 internal/alert/alert_test.go create mode 100644 internal/apprise/apprise.go create mode 100644 internal/apprise/apprise_test.go create mode 100644 internal/hub/ask.go create mode 100644 internal/hub/ask_test.go create mode 100644 internal/hub/hub.go create mode 100644 internal/store/alerts.go create mode 100644 internal/store/alerts_test.go create mode 100644 internal/store/migrations/005_alerts.sql create mode 100644 internal/web/hub_api.go create mode 100644 internal/web/hub_api_test.go 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())