Phase 1: Compass/NHS skeleton (Go, SQLite, session login, Docker, Caddy)

Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port
8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login
and GET /health.

- Go service (Go 1.25, modernc SQLite, no cgo) with its own image.
- SQLite schema per AGENTS.md §7: children, items, scans, notifications,
  session (internal/store/migrations/001_init.sql).
- Separate session login (signed cookie, CSRF double-submit), not SSO.
- HTMX server-rendered shell using the Family Home Lab tokens.css: canvas
  #f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs,
  dark mode, phone-friendly.
- Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or
  error banner, never as an empty day.
- Dockerfile + docker-compose.yml (own container, own port 8792; uses
  network_mode bridge because .13 has no free default Docker address pool).
- Caddy snippet for .35 in deploy/caddy/, deployment notes, and
  deploy/verify-phase1.sh.

No school scan in this phase; the Compass login and fetch are Phase 2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 13:21:20 +11:00
parent 2fb223b993
commit 7b98e487c0
29 changed files with 2287 additions and 0 deletions

93
deploy/verify-phase1.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Phase 1 definition of done (AUD-103).
#
# - the container is up and healthy
# - GET /health returns 200 with status ok
# - the login page renders and a session login succeeds
# - the Caddy route on .35 serves school.lab.audasmedia.com.au
#
# Run on .13 from the project folder. It reads the admin password from
# environment.d/school-service.env, never prints it, and never touches the
# school's systems.
set -u
BASE="${SCHOOL_VERIFY_BASE:-http://127.0.0.1:8792}"
PUBLIC="${SCHOOL_VERIFY_PUBLIC:-https://school.lab.audasmedia.com.au}"
ENV_FILE="${SCHOOL_ENV_FILE:-environment.d/school-service.env}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fail=0
pass() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
echo "== container =="
timeout 60 docker compose ps || true
echo
echo "== GET /health =="
code="$(curl -sS -o "$TMP/health.json" -w '%{http_code}' "$BASE/health" || echo 000)"
body="$(cat "$TMP/health.json" 2>/dev/null)"
printf 'HTTP %s\n%s\n' "$code" "$body"
if [ "$code" = "200" ] && printf '%s' "$body" | grep -q '"status":"ok"'; then
pass "/health 200 and status ok"
else
bad "/health did not return 200 status ok"
fi
echo
echo "== login page =="
curl -sS -c "$TMP/cookies" -o "$TMP/login.html" -w 'HTTP %{http_code}\n' "$BASE/login"
if grep -q 'name="csrf"' "$TMP/login.html"; then
pass "login page renders with a csrf token"
else
bad "login page did not render a csrf token"
fi
echo
echo "== session login =="
# Extract the plaintext password or use the hash-only path. With a hash-only
# env file the operator must supply SCHOOL_VERIFY_PASSWORD.
pw="${SCHOOL_VERIFY_PASSWORD:-}"
if [ -z "$pw" ] && [ -f "$ENV_FILE" ]; then
pw="$(sed -n 's/^SCHOOL_ADMIN_PASSWORD=//p' "$ENV_FILE" | head -1)"
fi
csrf="$(grep -o 'name="csrf" value="[^"]*"' "$TMP/login.html" | head -1 | sed 's/.*value="//; s/"//')"
if [ -z "$pw" ]; then
echo "SKIP no SCHOOL_VERIFY_PASSWORD and no plaintext password in $ENV_FILE"
elif [ -z "$csrf" ]; then
bad "no csrf token to submit"
else
code="$(curl -sS -b "$TMP/cookies" -c "$TMP/cookies" -o /dev/null -w '%{http_code}' \
-d "csrf=$csrf" -d "username=sam" -d "password=$pw" "$BASE/login")"
if [ "$code" = "303" ]; then
dash="$(curl -sS -b "$TMP/cookies" -o "$TMP/dash.html" -w '%{http_code}' "$BASE/")"
if [ "$dash" = "200" ] && grep -q 'What is due' "$TMP/dash.html"; then
pass "session login succeeds and the dashboard renders"
else
bad "dashboard did not render after login (HTTP $dash)"
fi
else
bad "login POST returned $code, expected 303"
fi
fi
echo
echo "== Caddy route (.35) =="
code="$(curl -sS -o "$TMP/pub.json" -D "$TMP/pub.headers" -w '%{http_code}' --max-time 25 "$PUBLIC/health" || echo 000)"
printf 'HTTP %s\n' "$code"
grep -i '^server:' "$TMP/pub.headers" 2>/dev/null || true
grep -i '^content-type:' "$TMP/pub.headers" 2>/dev/null || true
if [ "$code" = "200" ] && grep -qi '^server: Caddy' "$TMP/pub.headers"; then
pass "Caddy serves $PUBLIC/health"
else
bad "Caddy route did not answer 200 (HTTP $code)"
fi
echo
if [ "$fail" = "0" ]; then
echo "RESULT: Phase 1 verified"
else
echo "RESULT: Phase 1 verification FAILED"
fi
exit "$fail"