Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)

Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

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

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:41:07 +11:00
parent 4f233a8012
commit 3cf353d933
23 changed files with 2881 additions and 24 deletions

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ deploy/mosquitto/clients.env
# Pi agent generated memory (local state, not source) # Pi agent generated memory (local state, not source)
.pi/memory-vault/ .pi/memory-vault/
# Backups of the real env file (mode 600, secrets).
environment.d/*.bak*

View File

@@ -1,5 +1,5 @@
IMAGE ?= webrestrict/control:0.6.0-phase8 IMAGE ?= webrestrict/control:0.7.0-phase9
BUILD_VERSION ?= 0.6.0-phase8 BUILD_VERSION ?= 0.7.0-phase9
# Bounded docker helper for the Go toolchain. The container runs as the current # 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. # 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-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 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-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: test:
$(GO_RUN) go test ./... $(GO_RUN) go test ./...
@@ -82,3 +83,9 @@ mqtt-verify: mqtt-config
# broker subscriber receives the retained config. Never touches the 1883 plane. # broker subscriber receives the retained config. Never touches the 1883 plane.
verify-phase8: verify-phase8:
deploy/verify-phase8.sh 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

133
README.md
View File

@@ -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 per-device state and usage, and serves the web dashboard. Only it publishes
device config. device config.
This repository currently contains **Phase 8 — Overrides** (AGENTS.md §11), on This repository currently contains **Phase 9 — Hub and alerts** (AGENTS.md §11),
top of Phases 1–7: on top of Phases 1–8:
- Go service with its own image (`Dockerfile`) and compose file. - Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned - 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 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 for a short exemption. Both are recorded in `overrides` and `audit`, and a
state change is published retained before the API responds. 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. Enforcement itself (the kids' clients) arrives in later phases. Nothing here
Nothing here touches the router, Pi-hole, or DNS. touches the router, Pi-hole, or DNS.
## Quick start (on `.13`) ## Quick start (on `.13`)
@@ -62,7 +70,7 @@ docker compose up -d --build
# 3. Prove it. # 3. Prove it.
curl -s http://127.0.0.1:8791/health 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 <http://127.0.0.1:8791/> in a browser. It redirects to `/login`. Sign in Open <http://127.0.0.1:8791/> 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_PASSWORD` | — | its password |
| `CONTROL_MQTT_CA_FILE` | `/etc/webrestrict/tls/ca.crt` | PEM bundle that verifies the broker (TLS only) | | `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_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: Generate a bcrypt hash without host Go:
```bash ```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' 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 docker exec webrestrict-control /app/control mqtt-clients
``` ```
## Endpoints (Phase 2–7) ## Endpoints (Phase 2–9)
| Method | Path | Notes | | 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 append to the `audit` table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10). 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) ## Telemetry and usage (Phase 7)
Usage arrives on `restrict/<device>/telemetry` and is stored in `telemetry`: Usage arrives on `restrict/<device>/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 (`deploy/mosquitto/clients.env`) and a device that exists. It never restarts or
reconfigures anything shared. 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 ## Development
There is no host Go toolchain on `.13`; use the cached builder image. 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 logs # follow container logs
make mqtt-verify # prove TLS + credentials + ACLs on a throwaway broker 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-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 `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 ## 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/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock + grant expiry devices.go device registry, desired state, lock + grant expiry
schedules.go weekly windows and the effective state (incl. grants) schedules.go weekly windows and the effective state (incl. grants)
reports.go presence, telemetry, the daily rollup, top lists, audit cap 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 lists.go allow/block lists and device lookup by topic
overrides.go override history + the shared device keycode 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/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 internal/mqtt/ the broker link: retained config out, reports in
payload.go topic and payload contract for the kids' clients payload.go topic and payload contract for the kids' clients
link.go paho client, subscriptions, publish, TLS verification link.go paho client, subscriptions, publish, TLS verification
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers devices.go device registry + manual lock/unlock handlers
admin_api.go versioned admin API + the device keycode endpoint 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 schedules.go weekly schedule and block/allow editors
usage.go usage screen: week grid, top apps, top sites usage.go usage screen: week grid, top apps, top sites
templates/ login, dashboard, device form, editors, usage, partials 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) environment.d/ control-service.env.example (real file gitignored)
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
deploy/verify-phase8.sh lock latency proof
deploy/verify-phase9.sh Hub + alerts proof
``` ```
## Caddy ## Caddy
@@ -408,11 +514,16 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
`webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35` `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. (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 - 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. 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 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 - No HTTP telemetry ingest and no kids' clients (Phases 4–5). The topic and
payload contract above is what they implement. 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.

View File

@@ -16,14 +16,17 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
"golang.org/x/crypto/bcrypt" "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/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config" "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/mqtt"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web"
@@ -66,6 +69,12 @@ func main() {
case "rollup": case "rollup":
rollup(os.Args[2:]) rollup(os.Args[2:])
return return
case "alerts":
alertsCommand(os.Args[2:])
return
case "usage":
usageCommand(os.Args[2:])
return
case "version": case "version":
fmt.Println(version) fmt.Println(version)
return return
@@ -178,6 +187,22 @@ func run() error {
return err 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{ httpServer := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: srv.Handler(), 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:
//
// <id>\t<kind>\t<delivered|pending>\t<created>\t<device>\t<message>
//
// 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 // 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 // idempotent and safe to run while the service is up; it exits non-zero on a
// failure so a script can tell. // failure so a script can tell.

203
deploy/verify-phase9.sh Executable file
View File

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

View File

@@ -12,8 +12,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.6.0-phase8" VERSION: "0.7.0-phase9"
image: webrestrict/control:0.6.0-phase8 image: webrestrict/control:0.7.0-phase9
container_name: webrestrict-control container_name: webrestrict-control
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -38,7 +38,7 @@ CONTROL_ADMIN_USERNAME=sam
CONTROL_ADMIN_PASSWORD=change-me CONTROL_ADMIN_PASSWORD=change-me
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty. # 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$... # CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...
# --- Admin API (Phase 8) --------------------------------------------------- # --- 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). # The shared device keycode (one code, every device — AGENTS.md §10 rule 3).
# Give a bcrypt hash, never the code itself. Generate one with: # 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' # hash-password 'the-code'
# The hash is seeded into SQLite at start-up and can be changed later with # The hash is seeded into SQLite at start-up and can be changed later with
# `docker exec webrestrict-control /app/control keycode`. # `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 # unless this is true, and it logs a warning when it is. Leave it false against
# the shared Mosquitto (AGENTS.md §10 rule 6). # the shared Mosquitto (AGENTS.md §10 rule 6).
CONTROL_MQTT_INSECURE=false 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

263
internal/alert/alert.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}

123
internal/apprise/apprise.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -73,6 +73,30 @@ type Config struct {
// only until the shared Mosquitto gets the TLS listener; never set it in // only until the shared Mosquitto gets the TLS listener; never set it in
// production. // production.
MQTTInsecure bool 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. // Load reads and validates the configuration.
@@ -94,6 +118,15 @@ func Load() (*Config, error) {
MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"), MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"),
MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"), MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"),
MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false), 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") 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. // It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
const DefaultTimezone = "Australia/Sydney" 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. // MQTTTLS reports whether the broker URL asks for TLS.
func (c *Config) MQTTTLS() bool { func (c *Config) MQTTTLS() bool {
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://") return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
@@ -189,6 +240,35 @@ func envBool(key string, fallback bool) bool {
return b 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) { func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) { switch strings.ToLower(strings.TrimSpace(s)) {
case "debug": case "debug":

View File

@@ -129,3 +129,60 @@ func TestMQTTDefaultsAndGuards(t *testing.T) {
t.Error("MQTTEnabled = true, want false when CONTROL_MQTT_ENABLED=false") 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")
}
}

345
internal/hub/ask.go Normal file
View File

@@ -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)
}

211
internal/hub/ask_test.go Normal file
View File

@@ -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)
}
}

74
internal/hub/hub.go Normal file
View File

@@ -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}
}

224
internal/store/alerts.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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'))
);

View File

@@ -260,6 +260,79 @@ func (s *Store) TopUsage(ctx context.Context, id int64, from, to, kind string, l
return out, nil 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, // 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 // 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. // returns how many rows it removed. Nothing in the audit log is ever edited.

192
internal/web/hub_api.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -15,8 +15,10 @@ import (
"sync" "sync"
"time" "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/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config" "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" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
) )
@@ -40,6 +42,11 @@ type Server struct {
// code cannot be tried without limit. // code cannot be tried without limit.
keycodeMu sync.Mutex keycodeMu sync.Mutex
keycode map[string]keycodeAttempts 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. // 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 nil, fmt.Errorf("parse templates: %w", err)
} }
return &Server{ return &Server{
cfg: cfg, cfg: cfg,
store: st, store: st,
auth: am, auth: am,
tmpl: tmpl, tmpl: tmpl,
log: log, log: log,
pub: pub, pub: pub,
keycode: make(map[string]keycodeAttempts), keycode: make(map[string]keycodeAttempts),
answerer: hub.NewAnswerer(st, cfg.ScheduleLocation),
apprise: apprise.New(cfg.AppriseURL, cfg.AppriseKey, cfg.AppriseURLs, cfg.AppriseToken, 0),
}, nil }, 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 // publishConfig pushes one device's desired state to the broker, retained. A
// publish failure never fails the HTTP request: SQLite already holds the new // publish failure never fails the HTTP request: SQLite already holds the new
// state, and the link republishes everything when it reconnects, so the broker // 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). // not hold that secret. It is throttled and audited instead (admin_api.go).
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride) 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") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
panic("web: embedded static dir missing: " + err.Error()) panic("web: embedded static dir missing: " + err.Error())